Skip to main content

Overview

The SkyTeam ROBLOX miles system rewards passengers for flying with alliance airlines. Passengers earn miles during flights and can spend them on products offered by airlines through the admin panel.

Miles Balance

Each user has a miles balance stored in their profile:
packages/database/src/schema.ts
export const users = pgTable("users", {
  userId: text("userId").primaryKey(),
  username: text("username").notNull().unique(),
  displayName: text("displayName").notNull(),
  miles: integer("miles").notNull().default(0),
  avatarUrl: text("avatarUrl"),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
  updatedAt: timestamp("updatedAt").defaultNow().notNull(),
});

Default Balance

New users start with 0 miles. Miles are earned by participating in flights.

Earning Miles

Passengers earn miles by participating in flights. The amount is determined when they join a flight:
packages/database/src/schema.ts
export const flightPassengers = pgTable("flightPassengers", {
  flightId: uuid("flightId")
    .notNull()
    .references(() => flights.id),
  userId: text("userId")
    .notNull()
    .references(() => users.userId),
  miles: integer("miles").notNull(),
  joinedAt: timestamp("joinedAt").defaultNow().notNull(),
});

Incrementing Miles

The system provides a function to safely increment user miles:
packages/database/src/users.ts
export async function incrementMiles(
  userId: string,
  amount = 1,
  tx?: DbTransaction,
): Promise<User | null> {
  const dbInstance = tx || db;
  const result = await dbInstance
    .update(users)
    .set({ miles: increment(users.miles, amount) })
    .where(eq(users.userId, userId))
    .returning();
  return result[0] || null;
}

Transaction Support

The increment function supports database transactions for atomic operations.

Spending Miles

Users can spend miles on products through the airline’s catalog:
packages/database/src/users.ts
export async function spendMiles(
  userId: string,
  amount: number,
  note?: string,
): Promise<User | null> {
  if (amount <= 0) throw new Error("amount must be positive");

  return await db.transaction(async (tx) => {
    // Fetch user to check balance
    const userResult = await tx
      .select()
      .from(users)
      .where(eq(users.userId, userId))
      .limit(1);
    const user = userResult[0];

    if (!user) throw new Error("User not found");
    if (user.miles < amount) throw new Error("Insufficient miles");

    // Update miles
    const updated = await tx
      .update(users)
      .set({ miles: increment(users.miles, -amount) })
      .where(eq(users.userId, userId))
      .returning();

    // Add transaction record
    await addMilesTransaction(
      { userId, amount: -amount, type: "spend", source: "purchase", note },
      tx,
    );

    return updated[0] || null;
  });
}

Balance Validation

The system checks if users have sufficient miles before allowing purchases

Atomic Operations

Miles deduction and transaction logging happen atomically

Miles Products

Airlines can create products that users can purchase with miles:
packages/database/src/schema.ts
export const milesProducts = pgTable("milesProducts", {
  productId: text("productId").primaryKey(),
  airlineId: text("airlineId")
    .notNull()
    .references(() => airlines.airlineId),
  name: text("name").notNull(),
  description: text("description"),
  priceMiles: integer("priceMiles").notNull(),
  active: boolean("active").notNull().default(true),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
});

Fetching Products

Airlines can retrieve their product catalog:
apps/api/src/routes/airline.ts
router.get("/airline/fetchProductsData", async (_req, res, next) => {
  try {
    const airline = res.locals.airline as { airlineId: string };
    const products = await fetchMilesProducts(airline.airlineId);
    res.json(products);
  } catch (err) {
    next(err);
  }
});

Purchasing Products

Users can buy products through the API:
apps/api/src/routes/users.ts
router.post("/user/:id/buyProduct", async (req, res, next) => {
  try {
    const { id: userId } = req.params;
    const { productId } = req.body as { productId?: string };
    if (!productId)
      return res.status(400).json({ error: "Missing productId in body" });

    const airline = res.locals.airline as { airlineId: string };
    const [user, products] = await Promise.all([
      fetchUser(userId),
      fetchMilesProducts(airline.airlineId),
    ]);
    if (!user) return res.status(404).json({ error: "User not found" });

    const product = products.find(
      (p) => p.productId === productId && p.active !== false,
    );
    if (!product)
      return res.status(404).json({ error: "Product not found" });

    const updated = await spendMiles(
      userId,
      product.priceMiles,
      `Purchase: ${product.name}`,
    );

    res.json({ ok: true, user: updated, product });
  } catch (err) {
    if (err instanceof Error && err.message === "Insufficient miles") {
      return res.status(400).json({ error: "Insufficient miles" });
    }
    next(err);
  }
});

Transaction History

All miles activity is logged for transparency:
packages/database/src/schema.ts
export const milesTransactions = pgTable("milesTransactions", {
  id: uuid("id").defaultRandom().primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.userId),
  amount: integer("amount").notNull(), // positive for earn, negative for spend
  type: text("type").notNull(), // 'earn' | 'spend'
  source: text("source").notNull(), // e.g. 'flight' | 'purchase'
  flightId: uuid("flightId").references(() => flights.id),
  productId: text("productId"),
  note: text("note"),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
});

Recording Transactions

packages/database/src/miles.ts
export async function addMilesTransaction(
  data: {
    userId: string;
    amount: number; // positive for earn, negative for spend
    type: "earn" | "spend";
    source: "flight" | "purchase" | string;
    flightId?: string;
    productId?: string;
    note?: string;
  },
  tx?: DbTransaction,
): Promise<MilesTransaction> {
  const dbInstance = tx || db;
  const result = await dbInstance.insert(milesTransactions).values(data).returning();
  return result[0];
}

Viewing Transaction History

packages/database/src/miles.ts
export async function fetchMilesTransactions(
  userId: string,
): Promise<MilesTransaction[]> {
  return db
    .select()
    .from(milesTransactions)
    .where(eq(milesTransactions.userId, userId));
}

Best Practices

Use Transactions

Always use database transactions when modifying miles to ensure data consistency

Log Everything

Record all miles changes in the transaction history for audit trails

Validate Balances

Check user balances before allowing purchases to prevent negative balances

Clear Product Names

Use descriptive product names and descriptions for better user experience

Error Handling

The miles system provides clear error messages:
  • Insufficient miles: User doesn’t have enough miles for a purchase
  • User not found: Invalid user ID provided
  • Product not found: Product doesn’t exist or is inactive
  • Missing productId: Required parameter not provided

Next Steps

Fetch User Data

Learn how to retrieve user miles and flight history

Build docs developers (and LLMs) love