# Capstone Project: Loyalty Points API

Course: [API](https://www.devtutorials.ai/learn/building-apis) · Section: Beyond REST
Source: https://www.devtutorials.ai/learn/building-apis/beyond-rest/capstone

> Build a complete Loyalty Points API for Brew & Bytes: validated, retry-safe, authorized, versioned, documented and monitored.

A **capstone project** brings together everything you learned throughout the course and applies it to a real API feature.

In this lesson, you will build the Brew & Bytes **Loyalty Points API**.

Customers earn points when they make purchases and redeem those points later for rewards. Along the way, you will apply concepts such as API design, validation, authentication, authorization, versioning, caching, monitoring, rate limiting, testing, and documentation.

This is not a new topic. It is a practical application of all the topics you have already learned.

## Watch a Quick Loyalty API Go Wrong

The marketing team wanted a loyalty program delivered quickly. An API was written in a hurry. Press **Run**.

Several serious problems appear immediately:

### Duplicate Points

Order `101` is submitted twice because the app retries the request.

The server awards: 44 points

twice.

The customer receives double rewards.

### Free Points

A request claims an order amount of: ₹100000

The server trusts the value and awards: 10000 points

even though no such payment exists.

### Missing Authorization

The client provides any: customerId it wants.

Anyone can:

- Earn points for another account
- Read another customer's balance
- Spend another customer's points

The API technically works, but it trusts data that should never be trusted.

**In simple words:** every important security, validation, and ownership rule is missing.

## TL;DR

- Design resources, requests, responses, and status codes before writing code.
- Validate all incoming data.
- Take customer identity from the token, not from the request body.
- Require `Idempotency-Key` for write operations.
- Check authorization on every request.
- Return `404` for balances that belong to someone else.
- Return `409 Conflict` when business rules prevent an action.
- Add breaking changes in new API versions.
- Document the API with OpenAPI.
- Include a correlation id in responses and logs.

## A Real-World Example

Think about a real loyalty card program in a coffee shop.

The business defines rules such as: 1 point for every ₹10 spent

The cashier:

- Verifies the customer.
- Awards points once per purchase.
- Records every transaction.
- Prevents spending more points than available.
- Explains how a balance was calculated.

Later, the business introduces: Bronze, Silver and Gold tiers.

Existing cards continue to work while new features are added.

A production loyalty API follows the same principles.

## What You Will Learn

In this lesson, you will:

1. Design a loyalty API contract.
2. Validate requests properly.
3. Take ownership information from tokens.
4. Make writes retry-safe.
5. Enforce authorization rules.
6. Introduce API versioning.
7. Document the API.
8. Monitor and test the completed solution.

## Step 1: Design and Validate

The first endpoint is:

```http
POST /v1/loyalty/earn
```

Purpose:

```text
Add loyalty points after a purchase
```

The endpoint requires:

- Authentication
- Input validation
- Correct status codes

### No Token

Response:

```http
401 Unauthorized
```

Authentication is required.

### Invalid Amount

Request:

```json
{
  "amount": 0
}
```

Response:

```http
400 Bad Request
```

with:

```json
{
  "errors": {
    "amount": [...]
  }
}
```

### Fake Customer ID

Request:

```json
{
  "customerId": "customer-7"
}
```

Token:

```text
customer-8
```

Result:

Points are awarded to:

```text
customer-8
```

The body value is ignored.

### Valid Request

Request:

```json
{
  "amount": 120
}
```

Response:

```http
201 Created
```

Points earned:

```text
12
```

using:

```text
1 point per ₹10 spent
```

Business rules belong on the server. The client sends the purchase amount. The server calculates the points. The client never decides how many points are awarded.

## Step 2: Make Writes Safe and Secure

The next goal is to prevent duplicate points and unauthorized access.

### Prevent Duplicate Points

The first request earns: 44 points

A retry sends the same:

```http
Idempotency-Key
```

Instead of processing the request again, the server returns the original result.

Balance remains: 44 points and not 88 points

### Require an Idempotency Key

Missing key:

```http
400 Bad Request
```

Without a key, the API cannot distinguish:

- A retry
- A brand-new request

### Protect Customer Balances

Ravi attempts to:

- Read Asha's balance
- Redeem Asha's points

Both requests receive:

```http
404 Not Found
```

No balance information is revealed.

### Prevent Overspending

Asha has: 44 points

She attempts to spend: 100 points

Response:

```http
409 Conflict
```

The business rule prevents the operation.

### Valid Redemption

Spend: 30 points

Result: 44 → 14 points

Balance updates successfully.

Loyalty points represent real business value. Every write operation should verify: Who is making the request, whether the data belongs to them and whether the request is a retry. Missing any of these checks can lead to fraud or data corruption.

## Step 3: Version and Document the API

The business wants loyalty tiers: Bronze, Silver and Gold

Existing clients already use:

```http
/v1/loyalty/me
```

Changing the response directly would break those clients.

Instead, create a new version.

### Version 1

Response:

```json
{
  "points": 240
}
```

Existing applications continue to work.

### Version 2

Response:

```json
{
  "points": 240,
  "tier": "silver"
}
```

New clients gain access to the additional feature.

### OpenAPI Documentation

Endpoint:

```http
/openapi/v1.json
```

Documents:

- Routes
- Request bodies
- Response models
- Status codes
- Authentication requirements

Partners can integrate without needing manual instructions.

## Step 4: Test Like a Real Client

Every important scenario should become an automated test.

The tests verify:

- Earn points successfully
- Retry with the same idempotency key
- Prevent duplicate points
- Reject insufficient balances
- Prevent access to another customer's data
- Return correct status codes
- Return correlation ids

Each response includes:

```http
X-Correlation-Id
```

Support teams can use that identifier to locate the exact request in logs.

Whenever you fix a bug, add a test for it. If the bug ever returns, the test will fail immediately.

## Architecture of the Finished Loyalty API

```text
Client
   │
   ▼
Authentication
   │
   ▼
Authorization
   │
   ▼
Validation
   │
   ▼
Idempotency
   │
   ▼
Business Rules
   │
   ▼
Database
   │
   ▼
Logging & Monitoring
```

Each layer protects the system from a different type of failure.

## Your Checklist

| Requirement | Related Topic |
|---|---|
| API contract design | Introduction to APIs |
| Requests and responses | HTTP Basics |
| POST, GET, and Idempotency-Key | HTTP Methods |
| Resource naming | REST API Design |
| Status codes and Problem Details | Error Handling |
| Request validation | Validation and DTOs |
| Create and read balances | CRUD APIs |
| OpenAPI documentation | OpenAPI |
| Versioned endpoints | API Versioning |
| Bearer authentication | Authentication |
| Ownership checks | Authorization |
| Tier caching | Performance and Caching |
| Earn request limits | Rate Limiting |
| Correlation IDs | Monitoring and Logging |
| Automated client tests | API Testing |
| Single API gateway | API Gateway |
| Combined customer dashboard data | GraphQL |
| Internal service communication | gRPC |
| Tier upgrade notifications | Webhooks |

## Take It Home

Download the finished API from the last box as a real project, and run it on your own computer. The C# zip is an ASP.NET Core 10 project. The TypeScript zip is a Node.js project with Hono. Both include the same requests as a `.http` file.

## Where You See This

- Coffee shop loyalty systems
- Airline rewards programs
- E-commerce reward points
- Cashback and wallet systems
- Membership platforms
- Customer reward applications

These systems use the same principles:

- Authentication
- Authorization
- Validation
- Idempotency
- Monitoring
- Versioning
- Testing

## Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Customers receive duplicate points | Retries are processed twice | Require Idempotency-Key support |
| Users access other accounts | Ownership is not checked | Verify ownership from the token |
| Balances become negative | No balance validation exists | Return `409 Conflict` |
| Existing applications break after new features launch | Breaking changes are made in v1 | Create v2 endpoints |
| Support cannot trace failures | No correlation id exists | Add and log `X-Correlation-Id` |
| Users award themselves excessive points | The server trusts client values | Calculate rewards on the server |
| Security rules are bypassed | Authorization is missing on some endpoints | Authorize every read and write request |

## Exercises

## What to Remember

- A good API is designed, validated, secured, versioned, documented and watched.
- Take the identity from the **token**. Check the **owner** on every request.
- Writes that move points or money must be **safe to retry**.
- Use `409` for clashes, and use **Problem Details** for every error.
- Add new things in a **new version**.

**Next:** API Cheat Sheet. Keep it open while you build your own APIs.

var app = builder.Build();

var balances = new Dictionary<string, int> { ["customer-7"] = 0, ["customer-8"] = 0 };

// Version 0 of the loyalty card. It works... on a good day.
// It trusts the customerId in the body and never checks for retries.
app.MapPost("/loyalty/earn", (EarnRequest input) =>
{
    int points = input.Amount / 10; // 1 point per ₹10
    balances[input.CustomerId] += points;
    return Results.Ok(new { input.CustomerId, input.OrderId, points, balance = balances[input.CustomerId] });
});

app.MapGet("/loyalty/{customerId}", (string customerId) =>
    Results.Ok(new { customerId, points = balances[customerId] }));

app.Run();

record EarnRequest(string CustomerId, int OrderId, int Amount);`;

const balances = new Map<string, number>([["customer-7", 0], ["customer-8", 0]]);

const app = new Hono();

// Version 0 of the loyalty card. It works... on a good day.
// It trusts the customerId in the body and never checks for retries.
app.post("/loyalty/earn", async (c) => {
  const input = await c.req.json<{ customerId: string; orderId: number; amount: number }>();
  const points = Math.floor(input.amount / 10); // 1 point per ₹10
  balances.set(input.customerId, balances.get(input.customerId)! + points);
  return c.json({ customerId: input.customerId, orderId: input.orderId, points, balance: balances.get(input.customerId) }, 200);
});

app.get("/loyalty/:customerId", (c) => {
  const customerId = c.req.param("customerId");
  return c.json({ customerId, points: balances.get(customerId) }, 200);
});

Content-Type: application/json

{"customerId":"customer-7","orderId":101,"amount":440}

###
POST /loyalty/earn
Content-Type: application/json

{"customerId":"customer-7","orderId":101,"amount":440}

###
POST /loyalty/earn
Content-Type: application/json

{"customerId":"customer-7","orderId":102,"amount":100000}

###
GET /loyalty/customer-7`;

var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var balances = new Dictionary<string, int> { ["customer-7"] = 0, ["customer-8"] = 0 };
int nextId = 1;

// Who is calling? The token decides, never the body.
string? Owner(HttpContext context)
{
    string header = context.Request.Headers["Authorization"].ToString();
    return tokens.GetValueOrDefault(header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "");
}

app.MapPost("/v1/loyalty/earn", (EarnRequest input, HttpContext context) =>
{
    string? owner = Owner(context);
    if (owner == null) return Results.StatusCode(401);
    if (input.Amount < 1 || input.Amount > 100000)
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["amount"] = new[] { "Amount must be between 1 and 100000." }
        });
    int points = input.Amount / 10;
    balances[owner] += points;
    var earning = new Earning(nextId++, owner, input.OrderId, points, balances[owner]);
    return Results.Created("/v1/loyalty/earnings/" + earning.Id, earning);
});

app.Run();

// The request DTO has no customerId. A client cannot even ask to be someone else.
record EarnRequest(int OrderId, int Amount);
record Earning(int Id, string CustomerId, int OrderId, int Points, int Balance);`;

const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const balances = new Map<string, number>([["customer-7", 0], ["customer-8", 0]]);
let nextId = 1;

// Who is calling? The token decides, never the body.
function owner(c: Context): string | undefined {
  const header = c.req.header("Authorization") ?? "";
  return tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
}

const app = new Hono();

app.post("/v1/loyalty/earn", async (c) => {
  const who = owner(c);
  if (!who) return c.body(null, 401);
  // The request DTO has no customerId. A client cannot even ask to be someone else.
  const input = await c.req.json<{ orderId: number; amount: number }>();
  if (!(input.amount >= 1 && input.amount <= 100000)) {
    const problem = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.1", title: "One or more validation errors occurred.", status: 400, errors: { amount: ["Amount must be between 1 and 100000."] } };
    return c.body(JSON.stringify(problem), 400, { "Content-Type": "application/problem+json" });
  }
  const points = Math.floor(input.amount / 10);
  balances.set(who, balances.get(who)! + points);
  const earning = { id: nextId++, customerId: who, orderId: input.orderId, points, balance: balances.get(who)! };
  c.header("Location", "/v1/loyalty/earnings/" + earning.id);
  return c.json(earning, 201);
});

Content-Type: application/json

{"customerId":"customer-7","orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Authorization: Bearer token-asha
Content-Type: application/json

{"orderId":101,"amount":0}

###
POST /v1/loyalty/earn
Authorization: Bearer token-ravi
Content-Type: application/json

{"customerId":"customer-7","orderId":102,"amount":120}`;

var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var balances = new Dictionary<string, int> { ["customer-7"] = 0, ["customer-8"] = 0 };
var firstResults = new Dictionary<string, Receipt>(); // "customer:key" -> the first answer

string? Owner(HttpContext context)
{
    string header = context.Request.Headers["Authorization"].ToString();
    return tokens.GetValueOrDefault(header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "");
}

// Earn and redeem both share these steps: who are you, and have we seen this key before?
IResult Once(HttpContext context, Func<string, string, IResult> work)
{
    string? owner = Owner(context);
    if (owner == null) return Results.StatusCode(401);
    string key = context.Request.Headers["Idempotency-Key"].ToString();
    if (key == "") return Results.Problem(statusCode: 400, title: "Idempotency-Key header is required");
    if (firstResults.TryGetValue(owner + ":" + key, out var first)) return Results.Ok(first); // a retry: same answer, no new points
    return work(owner, owner + ":" + key);
}

app.MapPost("/v1/loyalty/earn", (EarnRequest input, HttpContext context) => Once(context, (owner, slot) =>
{
    balances[owner] += input.Amount / 10;
    var receipt = firstResults[slot] = new Receipt(owner, input.Amount / 10, balances[owner]);
    return Results.Ok(receipt);
}));

app.MapPost("/v1/loyalty/customers/{id}/redeem", (string id, RedeemRequest input, HttpContext context) => Once(context, (owner, slot) =>
{
    if (id != owner) return Results.NotFound(); // not yours: we do not even say it exists
    if (input.Points > balances[owner])
        return Results.Problem(statusCode: 409, title: "Not enough points",
            detail: "You have " + balances[owner] + " points. You asked for " + input.Points + ".");
    balances[owner] -= input.Points;
    var receipt = firstResults[slot] = new Receipt(owner, -input.Points, balances[owner]);
    return Results.Ok(receipt);
}));

app.MapGet("/v1/loyalty/customers/{id}", (string id, HttpContext context) =>
{
    string? owner = Owner(context);
    if (owner == null) return Results.StatusCode(401);
    return id == owner ? Results.Ok(new { customerId = id, points = balances[id] }) : Results.NotFound();
});

app.Run();

record EarnRequest(int OrderId, int Amount);
record RedeemRequest(int Points);
record Receipt(string CustomerId, int Points, int Balance);`;

type Receipt = { customerId: string; points: number; balance: number };
const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const balances = new Map<string, number>([["customer-7", 0], ["customer-8", 0]]);
const firstResults = new Map<string, Receipt>(); // "customer:key" -> the first answer

function owner(c: Context): string | undefined {
  const header = c.req.header("Authorization") ?? "";
  return tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
}

function problem(c: Context, status: number, type: string, title: string, extra: Record<string, unknown> = {}) {
  const body = { type: "https://tools.ietf.org/html/rfc9110#section-" + type, title, status, ...extra };
  return c.body(JSON.stringify(body), status as 400, { "Content-Type": "application/problem+json" });
}

// Earn and redeem both share these steps: who are you, and have we seen this key before?
async function once(c: Context, work: (who: string, slot: string) => Response | Promise<Response>) {
  const who = owner(c);
  if (!who) return c.body(null, 401);
  const key = c.req.header("Idempotency-Key") ?? "";
  if (key === "") return problem(c, 400, "15.5.1", "Idempotency-Key header is required");
  const first = firstResults.get(who + ":" + key);
  if (first) return c.json(first, 200); // a retry: same answer, no new points
  return work(who, who + ":" + key);
}

const app = new Hono();

app.post("/v1/loyalty/earn", (c) => once(c, async (who, slot) => {
  const input = await c.req.json<{ orderId: number; amount: number }>();
  balances.set(who, balances.get(who)! + Math.floor(input.amount / 10));
  const receipt = { customerId: who, points: Math.floor(input.amount / 10), balance: balances.get(who)! };
  firstResults.set(slot, receipt);
  return c.json(receipt, 200);
}));

app.post("/v1/loyalty/customers/:id/redeem", (c) => once(c, async (who, slot) => {
  if (c.req.param("id") !== who) return c.body(null, 404); // not yours: we do not even say it exists
  const input = await c.req.json<{ points: number }>();
  const balance = balances.get(who)!;
  if (input.points > balance)
    return problem(c, 409, "15.5.10", "Not enough points", { detail: "You have " + balance + " points. You asked for " + input.points + "." });
  balances.set(who, balance - input.points);
  const receipt = { customerId: who, points: -input.points, balance: balances.get(who)! };
  firstResults.set(slot, receipt);
  return c.json(receipt, 200);
}));

app.get("/v1/loyalty/customers/:id", (c) => {
  const who = owner(c);
  if (!who) return c.body(null, 401);
  const id = c.req.param("id");
  return id === who ? c.json({ customerId: id, points: balances.get(id) }, 200) : c.body(null, 404);
});

Authorization: Bearer token-asha
Idempotency-Key: earn-101
Content-Type: application/json

{"orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Authorization: Bearer token-asha
Idempotency-Key: earn-101
Content-Type: application/json

{"orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Authorization: Bearer token-asha
Content-Type: application/json

{"orderId":103,"amount":220}

###
GET /v1/loyalty/customers/customer-7
Authorization: Bearer token-ravi

###
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-ravi
Idempotency-Key: steal-1
Content-Type: application/json

{"points":40}

###
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Idempotency-Key: redeem-1
Content-Type: application/json

{"points":100}

###
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Idempotency-Key: redeem-2
Content-Type: application/json

{"points":30}`;

var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var balances = new Dictionary<string, int> { ["customer-7"] = 240, ["customer-8"] = 12 };

string? Owner(HttpContext context)
{
    string header = context.Request.Headers["Authorization"].ToString();
    return tokens.GetValueOrDefault(header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "");
}

string Tier(int points) => points >= 500 ? "gold" : points >= 100 ? "silver" : "bronze";

// v1 stays exactly as it was. Old apps keep working.
app.MapGet("/v1/loyalty/me", (HttpContext context) =>
    Owner(context) is string owner ? Results.Ok(new { points = balances[owner] }) : Results.StatusCode(401));

// v2 adds a field. New apps opt in by calling /v2.
app.MapGet("/v2/loyalty/me", (HttpContext context) =>
    Owner(context) is string owner
        ? Results.Ok(new { points = balances[owner], tier = Tier(balances[owner]) })
        : Results.StatusCode(401));

// A small hand-written OpenAPI document. Real projects generate it.
const string openApi = """
{
  "openapi": "3.1.1",
  "info": { "title": "Brew & Bytes Loyalty API", "version": "v1" },
  "components": { "securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } } },
  "security": [ { "bearer": [] } ],
  "paths": {
    "/v1/loyalty/earn": { "post": { "summary": "Earn 1 point per ₹10. Needs Idempotency-Key." } },
    "/v1/loyalty/customers/{id}/redeem": { "post": { "summary": "Redeem points. 409 if not enough." } },
    "/v1/loyalty/me": { "get": { "summary": "Your points." } },
    "/v2/loyalty/me": { "get": { "summary": "Your points and tier." } }
  }
}
""";
app.MapGet("/openapi/v1.json", () => Results.Text(openApi, "application/json"));

app.Run();`;

const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const balances = new Map<string, number>([["customer-7", 240], ["customer-8", 12]]);

function owner(c: Context): string | undefined {
  const header = c.req.header("Authorization") ?? "";
  return tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
}

const tier = (points: number) => (points >= 500 ? "gold" : points >= 100 ? "silver" : "bronze");

const app = new Hono();

// v1 stays exactly as it was. Old apps keep working.
app.get("/v1/loyalty/me", (c) => {
  const who = owner(c);
  return who ? c.json({ points: balances.get(who)! }, 200) : c.body(null, 401);
});

// v2 adds a field. New apps opt in by calling /v2.
app.get("/v2/loyalty/me", (c) => {
  const who = owner(c);
  return who ? c.json({ points: balances.get(who)!, tier: tier(balances.get(who)!) }, 200) : c.body(null, 401);
});

// A small hand-written OpenAPI document. Real projects generate it.
const openApi = \`
{
  "openapi": "3.1.1",
  "info": { "title": "Brew & Bytes Loyalty API", "version": "v1" },
  "components": { "securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } } },
  "security": [ { "bearer": [] } ],
  "paths": {
    "/v1/loyalty/earn": { "post": { "summary": "Earn 1 point per ₹10. Needs Idempotency-Key." } },
    "/v1/loyalty/customers/{id}/redeem": { "post": { "summary": "Redeem points. 409 if not enough." } },
    "/v1/loyalty/me": { "get": { "summary": "Your points." } },
    "/v2/loyalty/me": { "get": { "summary": "Your points and tier." } }
  }
}\`;
app.get("/openapi/v1.json", (c) => c.body(openApi.trim(), 200, { "Content-Type": "application/json" }));

Authorization: Bearer token-asha

###
GET /v2/loyalty/me
Authorization: Bearer token-asha

###
GET /v2/loyalty/me
Authorization: Bearer token-ravi

###
GET /openapi/v1.json`;

var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var balances = new Dictionary<string, int> { ["customer-7"] = 0, ["customer-8"] = 0 };
var firstResults = new Dictionary<string, Receipt>();

// Monitoring: every response carries the caller's X-Correlation-Id, so a bug report can be traced.
app.Use(async (context, next) =>
{
    string id = context.Request.Headers["X-Correlation-Id"].ToString();
    context.Response.Headers["X-Correlation-Id"] = id == "" ? "none" : id;
    await next();
});

string? Owner(HttpContext context)
{
    string header = context.Request.Headers["Authorization"].ToString();
    return tokens.GetValueOrDefault(header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "");
}

app.MapPost("/v1/loyalty/earn", (EarnRequest input, HttpContext context) =>
{
    if (Owner(context) is not string owner) return Results.StatusCode(401);
    string slot = owner + ":" + context.Request.Headers["Idempotency-Key"];
    if (firstResults.TryGetValue(slot, out var first)) return Results.Ok(first);
    balances[owner] += input.Amount / 10;
    return Results.Ok(firstResults[slot] = new Receipt(owner, input.Amount / 10, balances[owner]));
});

app.MapPost("/v1/loyalty/customers/{id}/redeem", (string id, RedeemRequest input, HttpContext context) =>
{
    if (Owner(context) != id) return Results.NotFound();
    if (input.Points > balances[id])
        return Results.Problem(statusCode: 409, title: "Not enough points",
            detail: "You have " + balances[id] + " points. You asked for " + input.Points + ".");
    balances[id] -= input.Points;
    return Results.Ok(new Receipt(id, -input.Points, balances[id]));
});

app.MapGet("/v1/loyalty/customers/{id}", (string id, HttpContext context) =>
    Owner(context) == id ? Results.Ok(new { customerId = id, points = balances[id] }) : Results.NotFound());

app.Run();

record EarnRequest(int OrderId, int Amount);
record RedeemRequest(int Points);
record Receipt(string CustomerId, int Points, int Balance);`;

type Receipt = { customerId: string; points: number; balance: number };
const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const balances = new Map<string, number>([["customer-7", 0], ["customer-8", 0]]);
const firstResults = new Map<string, Receipt>();

const app = new Hono();

// Monitoring: every response carries the caller's X-Correlation-Id, so a bug report can be traced.
app.use("*", async (c, next) => {
  const id = c.req.header("X-Correlation-Id") ?? "";
  c.header("X-Correlation-Id", id === "" ? "none" : id);
  await next();
});

function owner(c: Context): string | undefined {
  const header = c.req.header("Authorization") ?? "";
  return tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
}

app.post("/v1/loyalty/earn", async (c) => {
  const who = owner(c);
  if (!who) return c.body(null, 401);
  const slot = who + ":" + (c.req.header("Idempotency-Key") ?? "");
  const first = firstResults.get(slot);
  if (first) return c.json(first, 200);
  const input = await c.req.json<{ orderId: number; amount: number }>();
  balances.set(who, balances.get(who)! + Math.floor(input.amount / 10));
  const receipt = { customerId: who, points: Math.floor(input.amount / 10), balance: balances.get(who)! };
  firstResults.set(slot, receipt);
  return c.json(receipt, 200);
});

app.post("/v1/loyalty/customers/:id/redeem", async (c) => {
  const id = c.req.param("id");
  if (owner(c) !== id) return c.body(null, 404);
  const input = await c.req.json<{ points: number }>();
  const balance = balances.get(id)!;
  if (input.points > balance) {
    const problem = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.10", title: "Not enough points", status: 409, detail: "You have " + balance + " points. You asked for " + input.points + "." };
    return c.body(JSON.stringify(problem), 409, { "Content-Type": "application/problem+json" });
  }
  balances.set(id, balance - input.points);
  return c.json({ customerId: id, points: -input.points, balance: balances.get(id)! }, 200);
});

app.get("/v1/loyalty/customers/:id", (c) => {
  const id = c.req.param("id");
  return owner(c) === id ? c.json({ customerId: id, points: balances.get(id) }, 200) : c.body(null, 404);
});

POST /v1/loyalty/earn
Authorization: Bearer token-asha
Idempotency-Key: earn-101
X-Correlation-Id: check-1
Content-Type: application/json

{"orderId":101,"amount":440}

### Check 2: a retry with the same key does not add points
POST /v1/loyalty/earn
Authorization: Bearer token-asha
Idempotency-Key: earn-101
X-Correlation-Id: check-2
Content-Type: application/json

{"orderId":101,"amount":440}

### Check 3: redeeming too much is a 409
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Idempotency-Key: redeem-1
X-Correlation-Id: check-3
Content-Type: application/json

{"points":500}

### Check 4: Ravi cannot see Asha's points
GET /v1/loyalty/customers/customer-7
Authorization: Bearer token-ravi
X-Correlation-Id: check-4`;

  {
    prompt: "Fix Points(amount). Give 1 point for every full ₹10 (round down), give 0 points under ₹10, and give at most 500 points per order.",
    hint: "Use integer division to round down. Then limit the result with Math.Min.",
    explain: "Integer division drops the remainder, so ₹447 gives 44. The cap stops one huge order from earning thousands of points.",
    csharp: { starter: `// Brew & Bytes loyalty rules:
//   1. 1 point for every full ₹10 (round DOWN: ₹447 gives 44).
//   2. Under ₹10 gives 0 points.
//   3. At most 500 points for one order.
// TODO: fix Points so all three rules hold.
int Points(int amount)
{
    return (int)Math.Round(amount / 10.0);
}

foreach (int amount in new[] { 9, 10, 447, 6000 })
    Console.WriteLine("₹" + amount + " -> " + Points(amount) + " points");`, solution: `// Brew & Bytes loyalty rules:
//   1. 1 point for every full ₹10 (round DOWN: ₹447 gives 44).
//   2. Under ₹10 gives 0 points.
//   3. At most 500 points for one order.
int Points(int amount)
{
    if (amount < 10) return 0;          // rule 2
    return Math.Min(amount / 10, 500);  // rule 1: int division rounds down; rule 3: cap
}

foreach (int amount in new[] { 9, 10, 447, 6000 })
    Console.WriteLine("₹" + amount + " -> " + Points(amount) + " points");`, mode: "console" },
    react: { starter: `// Brew & Bytes loyalty rules:
//   1. 1 point for every full ₹10 (round DOWN: ₹447 gives 44).
//   2. Under ₹10 gives 0 points.
//   3. At most 500 points for one order.
// TODO: fix points so all three rules hold.
function points(amount: number): number {
  return Math.round(amount / 10);
}

for (const amount of [9, 10, 447, 6000]) console.log("₹" + amount + " -> " + points(amount) + " points");`, solution: `// Brew & Bytes loyalty rules:
//   1. 1 point for every full ₹10 (round DOWN: ₹447 gives 44).
//   2. Under ₹10 gives 0 points.
//   3. At most 500 points for one order.
function points(amount: number): number {
  if (amount < 10) return 0; // rule 2
  return Math.min(Math.floor(amount / 10), 500); // rule 1: round down; rule 3: cap
}

for (const amount of [9, 10, 447, 6000]) console.log("₹" + amount + " -> " + points(amount) + " points");`, mode: "console" }
  },
  {
    prompt: "When a customer redeems more points than the balance, return 409 Conflict as Problem Details. The detail must be: You have 44 points. You asked for 100.",
    hint: "Before you subtract, compare the requested points with the balance. If the request is too big, return a problem with status 409.",
    explain: "The request is valid, but it clashes with the balance. The 409 tells the app to show the real balance.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var balances = new Dictionary<string, int> { ["customer-7"] = 44, ["customer-8"] = 12 };

string? Owner(HttpContext context)
{
    string header = context.Request.Headers["Authorization"].ToString();
    return tokens.GetValueOrDefault(header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "");
}

app.MapPost("/v1/loyalty/customers/{id}/redeem", (string id, RedeemRequest input, HttpContext context) =>
{
    if (Owner(context) != id) return Results.NotFound();
    // TODO: if input.Points is more than the balance, return 409 Problem Details:
    //   title "Not enough points", detail "You have 44 points. You asked for 100."
    balances[id] -= input.Points;
    return Results.Ok(new { customerId = id, points = -input.Points, balance = balances[id] });
});

app.Run();

record RedeemRequest(int Points);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var tokens = new Dictionary<string, string> { ["token-asha"] = "customer-7", ["token-ravi"] = "customer-8" };
var balances = new Dictionary<string, int> { ["customer-7"] = 44, ["customer-8"] = 12 };

string? Owner(HttpContext context)
{
    string header = context.Request.Headers["Authorization"].ToString();
    return tokens.GetValueOrDefault(header.StartsWith("Bearer ") ? header["Bearer ".Length..] : "");
}

app.MapPost("/v1/loyalty/customers/{id}/redeem", (string id, RedeemRequest input, HttpContext context) =>
{
    if (Owner(context) != id) return Results.NotFound();
    // More than the balance? Refuse with 409 and say why.
    if (input.Points > balances[id])
        return Results.Problem(statusCode: 409, title: "Not enough points",
            detail: "You have " + balances[id] + " points. You asked for " + input.Points + ".");
    balances[id] -= input.Points;
    return Results.Ok(new { customerId = id, points = -input.Points, balance = balances[id] });
});

app.Run();

record RedeemRequest(int Points);`, mode: "aspnet", requests: `POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Content-Type: application/json

{"points":100}

###
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Content-Type: application/json

{"points":30}` },
    react: { starter: `import { Hono } from "hono";

const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const balances = new Map<string, number>([["customer-7", 44], ["customer-8", 12]]);

function owner(c: Context): string | undefined {
  const header = c.req.header("Authorization") ?? "";
  return tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
}

const app = new Hono();

app.post("/v1/loyalty/customers/:id/redeem", async (c) => {
  const id = c.req.param("id");
  if (owner(c) !== id) return c.body(null, 404);
  const input = await c.req.json<{ points: number }>();
  const balance = balances.get(id)!;
  // TODO: if input.points is more than the balance, return 409 Problem Details:
  //   title "Not enough points", detail "You have 44 points. You asked for 100."
  balances.set(id, balance - input.points);
  return c.json({ customerId: id, points: -input.points, balance: balances.get(id)! }, 200);
});

const tokens = new Map<string, string>([["token-asha", "customer-7"], ["token-ravi", "customer-8"]]);
const balances = new Map<string, number>([["customer-7", 44], ["customer-8", 12]]);

function owner(c: Context): string | undefined {
  const header = c.req.header("Authorization") ?? "";
  return tokens.get(header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "");
}

const app = new Hono();

app.post("/v1/loyalty/customers/:id/redeem", async (c) => {
  const id = c.req.param("id");
  if (owner(c) !== id) return c.body(null, 404);
  const input = await c.req.json<{ points: number }>();
  const balance = balances.get(id)!;
  // More than the balance? Refuse with 409 and say why.
  if (input.points > balance) {
    const problem = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.10", title: "Not enough points", status: 409, detail: "You have " + balance + " points. You asked for " + input.points + "." };
    return c.body(JSON.stringify(problem), 409, { "Content-Type": "application/problem+json" });
  }
  balances.set(id, balance - input.points);
  return c.json({ customerId: id, points: -input.points, balance: balances.get(id)! }, 200);
});

Authorization: Bearer token-asha
Content-Type: application/json

{"points":100}

###
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Content-Type: application/json

{"points":30}` },
    checks: `POST /v1/loyalty/customers/customer-8/redeem
Authorization: Bearer token-ravi
Content-Type: application/json

{"points":20}

###
POST /v1/loyalty/customers/customer-8/redeem
Authorization: Bearer token-ravi
Content-Type: application/json

{"points":12}

###
POST /v1/loyalty/customers/customer-7/redeem
Authorization: Bearer token-asha
Content-Type: application/json

{"points":15}`
  },
  {
    prompt: "A retried earn with the same Idempotency-Key adds the points again. Remember the first result for each key, and return it when the same key comes again.",
    hint: "Keep a dictionary from the key to the first receipt. Check it before you add any points.",
    explain: "The same key always gets the same answer, so a weak network can never double the points.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var balances = new Dictionary<string, int> { ["customer-7"] = 0 };
// TODO: remember the first Receipt for each Idempotency-Key.
// If the same key comes again, return that first Receipt and add no points.

app.MapPost("/v1/loyalty/earn", (EarnRequest input, HttpContext context) =>
{
    string owner = "customer-7"; // pretend the token said Asha, to keep this short
    string key = context.Request.Headers["Idempotency-Key"].ToString();

    balances[owner] += input.Amount / 10;
    var receipt = new Receipt(owner, input.Amount / 10, balances[owner]);

    return Results.Ok(receipt);
});

app.Run();

record EarnRequest(int OrderId, int Amount);
record Receipt(string CustomerId, int Points, int Balance);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var balances = new Dictionary<string, int> { ["customer-7"] = 0 };
var firstResults = new Dictionary<string, Receipt>(); // key -> the first answer

app.MapPost("/v1/loyalty/earn", (EarnRequest input, HttpContext context) =>
{
    string owner = "customer-7"; // pretend the token said Asha, to keep this short
    string key = context.Request.Headers["Idempotency-Key"].ToString();
    if (firstResults.TryGetValue(key, out var first)) return Results.Ok(first); // a retry: same answer
    balances[owner] += input.Amount / 10;
    var receipt = new Receipt(owner, input.Amount / 10, balances[owner]);
    firstResults[key] = receipt;
    return Results.Ok(receipt);
});

app.Run();

record EarnRequest(int OrderId, int Amount);
record Receipt(string CustomerId, int Points, int Balance);`, mode: "aspnet", requests: `POST /v1/loyalty/earn
Idempotency-Key: earn-101
Content-Type: application/json

{"orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Idempotency-Key: earn-101
Content-Type: application/json

{"orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Idempotency-Key: earn-103
Content-Type: application/json

{"orderId":103,"amount":180}` },
    react: { starter: `import { Hono } from "hono";

type Receipt = { customerId: string; points: number; balance: number };
const balances = new Map<string, number>([["customer-7", 0]]);
// TODO: remember the first Receipt for each Idempotency-Key.
// If the same key comes again, return that first Receipt and add no points.

const app = new Hono();

app.post("/v1/loyalty/earn", async (c) => {
  const owner = "customer-7"; // pretend the token said Asha, to keep this short
  const key = c.req.header("Idempotency-Key") ?? "";

  const input = await c.req.json<{ orderId: number; amount: number }>();
  balances.set(owner, balances.get(owner)! + Math.floor(input.amount / 10));
  const receipt: Receipt = { customerId: owner, points: Math.floor(input.amount / 10), balance: balances.get(owner)! };

  return c.json(receipt, 200);
});

type Receipt = { customerId: string; points: number; balance: number };
const balances = new Map<string, number>([["customer-7", 0]]);
const firstResults = new Map<string, Receipt>(); // key -> the first answer

const app = new Hono();

app.post("/v1/loyalty/earn", async (c) => {
  const owner = "customer-7"; // pretend the token said Asha, to keep this short
  const key = c.req.header("Idempotency-Key") ?? "";
  const first = firstResults.get(key);
  if (first) return c.json(first, 200); // a retry: same answer
  const input = await c.req.json<{ orderId: number; amount: number }>();
  balances.set(owner, balances.get(owner)! + Math.floor(input.amount / 10));
  const receipt: Receipt = { customerId: owner, points: Math.floor(input.amount / 10), balance: balances.get(owner)! };
  firstResults.set(key, receipt);
  return c.json(receipt, 200);
});

Idempotency-Key: earn-101
Content-Type: application/json

{"orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Idempotency-Key: earn-101
Content-Type: application/json

{"orderId":101,"amount":440}

###
POST /v1/loyalty/earn
Idempotency-Key: earn-103
Content-Type: application/json

{"orderId":103,"amount":180}` },
    checks: `POST /v1/loyalty/earn
Idempotency-Key: earn-103
Content-Type: application/json

{"orderId":103,"amount":999}

###
POST /v1/loyalty/earn
Idempotency-Key: earn-104
Content-Type: application/json

{"orderId":104,"amount":250}

###
POST /v1/loyalty/earn
Idempotency-Key: earn-104
Content-Type: application/json

{"orderId":104,"amount":250}`
  }
];
