README.md
+122
-0
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..813f220
@@ -0,0 +1,122 @@
# Walley Backend Developer Code Challenge
## Introduction
Welcome! We're excited to see how you approach building backend payment services at Walley.
This challenge uses a simplified **Checkout API** — a service that handles orders and refunds. The project is partially built: one endpoint works, the structure is in place, and your job is to extend, fix, and test it.
**Time expectation:** 2-3 hours
**What we're looking for:**
- Solid C# and .NET fundamentals
- Clean, readable code that follows existing patterns
- Thoughtful error handling and validation
- Testing instincts
- Pragmatic decision-making
## Getting Started
```bash
cd src/Walley.Checkout.Api
dotnet restore
dotnet run
```
The API runs at `https://localhost:5001` (or `http://localhost:5000`). Swagger UI is available at `/swagger`.
To run the existing tests:
```bash
cd src/Walley.Checkout.Api.Tests
dotnet test
```
## The Scenario
You're working on Walley's Checkout API. The service handles customer orders — creating them, retrieving them, processing refunds, and calculating totals. A colleague started building it but moved to another team. You're picking up where they left off.
Take a few minutes to read through the existing code before starting. The project has a clear structure: controllers, services, models — and one fully working endpoint (`GET /api/orders`) as a reference.
## Your Tasks
### Task 1: Add the "Create Order" Endpoint
Implement `POST /api/orders` that accepts a new order and returns the created order.
**Requirements:**
- Accept a request body with customer info and order lines
- Validate the input (no empty orders, valid amounts, required fields)
- Calculate the total from the order lines
- Assign a new ID and set the initial status to `Pending`
- Return `201 Created` with the order and a `Location` header
Look at the existing models and the `GET` endpoint for conventions to follow.
### Task 2: Fix the Bug in `RefundService`
The `ProcessRefundAsync` method in `RefundService` has a bug. It doesn't work as intended.
Find it, fix it, and briefly explain (in a code comment) what was wrong.
### Task 3: Write Unit Tests for `OrderService`
The `OrderService` has no tests. Write unit tests that cover the important behaviors.
xUnit and NSubstitute are already referenced in the test project. Focus on meaningful tests — quality over quantity.
### Task 4: Improve Error Handling
The `GET /api/orders/{id}` endpoint currently returns a generic `500` if anything goes wrong. Improve the error handling so the API returns appropriate status codes and useful error responses.
Consider: what should happen when an order isn't found? When the ID format is invalid? How should unexpected errors be handled?
### Task 5 (Open-ended): What Would You Improve?
If you had more time, what would you change or add? Write your thoughts in a short section at the bottom of this README or as a separate `IMPROVEMENTS.md` file.
This isn't about writing a lot — a few bullet points or a paragraph is fine. We're curious about what catches your eye.
## Project Structure
```
src/
├── Walley.Checkout.Api/
│ ├── Controllers/
│ │ └── OrdersController.cs # API endpoints
│ ├── Services/
│ │ ├── IOrderService.cs # Order service interface
│ │ ├── OrderService.cs # Order business logic
│ │ ├── IRefundService.cs # Refund service interface
│ │ └── RefundService.cs # Refund logic (has a bug!)
│ ├── Models/
│ │ ├── Order.cs # Order domain model
│ │ ├── OrderLine.cs # Order line item
│ │ ├── Refund.cs # Refund model
│ │ └── ApiErrorResponse.cs # Error response DTO
│ ├── Middleware/
│ │ └── ExceptionMiddleware.cs # Global exception handler
│ ├── Program.cs
│ └── Walley.Checkout.Api.csproj
├── Walley.Checkout.Api.Tests/
│ ├── RefundServiceTests.cs # Example tests (for reference)
│ └── Walley.Checkout.Api.Tests.csproj
```
## Tips
- **Follow existing patterns** — consistency matters more than cleverness
- **Start with Task 1**, it builds your understanding of the codebase
- **Don't over-engineer** — this is a small service, treat it like one
- **Commit as you go** — we like seeing your thought process
- **Read the existing code first** — the answers are in the patterns
## Questions?
If anything is unclear, reach out to me at bob.jelica@walley.se — we're happy to help.
---
**Note:** We don't expect perfection in 2-3 hours. We want to see how you think, prioritize, and write code in a real-world codebase. Quality over quantity.
Good luck! 🚀
Walley.Checkout.sln
+25
-0
diff --git a/Walley.Checkout.sln b/Walley.Checkout.sln
new file mode 100644
index 0000000..609be18
@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walley.Checkout.Api", "src\Walley.Checkout.Api\Walley.Checkout.Api.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Walley.Checkout.Api.Tests", "src\Walley.Checkout.Api.Tests\Walley.Checkout.Api.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
src/Walley.Checkout.Api.Tests/RefundServiceTests.cs
+61
-0
diff --git a/src/Walley.Checkout.Api.Tests/RefundServiceTests.cs b/src/Walley.Checkout.Api.Tests/RefundServiceTests.cs
new file mode 100644
index 0000000..2d342c4
@@ -0,0 +1,61 @@
using NSubstitute;
using FluentAssertions;
using Xunit;
using Walley.Checkout.Api.Models;
using Walley.Checkout.Api.Services;
namespace Walley.Checkout.Api.Tests;
public class RefundServiceTests
{
private readonly IOrderService _orderService;
private readonly RefundService _sut;
public RefundServiceTests()
{
_orderService = Substitute.For<IOrderService>();
_sut = new RefundService(_orderService);
}
[Fact]
public async Task ProcessRefundAsync_WithValidCompletedOrder_ShouldApproveRefund()
{
// Arrange
var order = new Order
{
Id = "ORD-001",
Status = OrderStatus.Completed,
TotalAmount = 500.00m
};
_orderService.GetOrderByIdAsync("ORD-001").Returns(order);
// Act
var result = await _sut.ProcessRefundAsync("ORD-001", 200.00m, "Changed my mind");
// Assert
result.Status.Should().Be(RefundStatus.Approved);
result.Amount.Should().Be(200.00m);
result.OrderId.Should().Be("ORD-001");
}
[Fact]
public async Task ProcessRefundAsync_WithAmountExceedingTotal_ShouldRejectRefund()
{
// Arrange
var order = new Order
{
Id = "ORD-001",
Status = OrderStatus.Completed,
TotalAmount = 500.00m
};
_orderService.GetOrderByIdAsync("ORD-001").Returns(order);
// Act
var result = await _sut.ProcessRefundAsync("ORD-001", 600.00m, "Full refund please");
// Assert
result.Status.Should().Be(RefundStatus.Rejected);
}
}
src/Walley.Checkout.Api.Tests/Walley.Checkout.Api.Tests.csproj
+30
-0
diff --git a/src/Walley.Checkout.Api.Tests/Walley.Checkout.Api.Tests.csproj b/src/Walley.Checkout.Api.Tests/Walley.Checkout.Api.Tests.csproj
new file mode 100644
index 0000000..1012f11
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="coverlet.collector" Version="6.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Walley.Checkout.Api\Walley.Checkout.Api.csproj" />
</ItemGroup>
</Project>
src/Walley.Checkout.Api/Controllers/OrdersController.cs
+71
-0
diff --git a/src/Walley.Checkout.Api/Controllers/OrdersController.cs b/src/Walley.Checkout.Api/Controllers/OrdersController.cs
new file mode 100644
index 0000000..658c320
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using Walley.Checkout.Api.Models;
using Walley.Checkout.Api.Services;
namespace Walley.Checkout.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
private readonly IRefundService _refundService;
public OrdersController(IOrderService orderService, IRefundService refundService)
{
_orderService = orderService;
_refundService = refundService;
}
/// <summary>
/// Get all orders.
/// </summary>
[HttpGet]
public async Task<ActionResult<IEnumerable<Order>>> GetAll()
{
var orders = await _orderService.GetAllOrdersAsync();
return Ok(orders);
}
/// <summary>
/// Get a specific order by ID.
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<Order>> GetById(string id)
{
// Task 4: This error handling needs improvement.
// Right now any issue results in a generic 500 from the exception middleware.
var order = await _orderService.GetOrderByIdAsync(id);
return Ok(order);
}
// Task 1: Implement POST /api/orders
// See README for requirements.
/// <summary>
/// Request a refund for an order.
/// </summary>
[HttpPost("{orderId}/refunds")]
public async Task<ActionResult<Refund>> RequestRefund(
string orderId,
[FromBody] RefundRequest request)
{
var refund = await _refundService.ProcessRefundAsync(
orderId,
request.Amount,
request.Reason);
if (refund.Status == RefundStatus.Rejected)
{
return UnprocessableEntity(refund);
}
return Ok(refund);
}
}
public class RefundRequest
{
public decimal Amount { get; set; }
public string Reason { get; set; } = string.Empty;
}
src/Walley.Checkout.Api/Middleware/ExceptionMiddleware.cs
+41
-0
diff --git a/src/Walley.Checkout.Api/Middleware/ExceptionMiddleware.cs b/src/Walley.Checkout.Api/Middleware/ExceptionMiddleware.cs
new file mode 100644
index 0000000..fd792ea
@@ -0,0 +1,41 @@
using System.Net;
using System.Text.Json;
using Walley.Checkout.Api.Models;
namespace Walley.Checkout.Api.Middleware;
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
var response = new ApiErrorResponse
{
Message = "An unexpected error occurred.",
StatusCode = context.Response.StatusCode
};
var json = JsonSerializer.Serialize(response);
await context.Response.WriteAsync(json);
}
}
}
src/Walley.Checkout.Api/Models/ApiErrorResponse.cs
+8
-0
diff --git a/src/Walley.Checkout.Api/Models/ApiErrorResponse.cs b/src/Walley.Checkout.Api/Models/ApiErrorResponse.cs
new file mode 100644
index 0000000..513764e
@@ -0,0 +1,8 @@
namespace Walley.Checkout.Api.Models;
public class ApiErrorResponse
{
public string Message { get; set; } = string.Empty;
public string? Detail { get; set; }
public int StatusCode { get; set; }
}
src/Walley.Checkout.Api/Models/Order.cs
+23
-0
diff --git a/src/Walley.Checkout.Api/Models/Order.cs b/src/Walley.Checkout.Api/Models/Order.cs
new file mode 100644
index 0000000..5b18619
@@ -0,0 +1,23 @@
namespace Walley.Checkout.Api.Models;
public class Order
{
public string Id { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public List<OrderLine> Lines { get; set; } = new();
public decimal TotalAmount { get; set; }
public string Currency { get; set; } = "SEK";
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
public enum OrderStatus
{
Pending,
Confirmed,
Completed,
Cancelled,
Refunded
}
src/Walley.Checkout.Api/Models/OrderLine.cs
+9
-0
diff --git a/src/Walley.Checkout.Api/Models/OrderLine.cs b/src/Walley.Checkout.Api/Models/OrderLine.cs
new file mode 100644
index 0000000..c10e866
@@ -0,0 +1,9 @@
namespace Walley.Checkout.Api.Models;
public class OrderLine
{
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal => Quantity * UnitPrice;
}
src/Walley.Checkout.Api/Models/Refund.cs
+18
-0
diff --git a/src/Walley.Checkout.Api/Models/Refund.cs b/src/Walley.Checkout.Api/Models/Refund.cs
new file mode 100644
index 0000000..5cd68ca
@@ -0,0 +1,18 @@
namespace Walley.Checkout.Api.Models;
public class Refund
{
public string Id { get; set; } = string.Empty;
public string OrderId { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Reason { get; set; } = string.Empty;
public RefundStatus Status { get; set; } = RefundStatus.Pending;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum RefundStatus
{
Pending,
Approved,
Rejected
}
src/Walley.Checkout.Api/Program.cs
+34
-0
diff --git a/src/Walley.Checkout.Api/Program.cs b/src/Walley.Checkout.Api/Program.cs
new file mode 100644
index 0000000..907049b
@@ -0,0 +1,34 @@
using Walley.Checkout.Api.Middleware;
using Walley.Checkout.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "Walley Checkout API",
Version = "v1",
Description = "A simplified checkout service for managing orders and refunds."
});
});
builder.Services.AddSingleton<IOrderService, OrderService>();
builder.Services.AddScoped<IRefundService, RefundService>();
var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
src/Walley.Checkout.Api/Services/IOrderService.cs
+10
-0
diff --git a/src/Walley.Checkout.Api/Services/IOrderService.cs b/src/Walley.Checkout.Api/Services/IOrderService.cs
new file mode 100644
index 0000000..86fc186
@@ -0,0 +1,10 @@
using Walley.Checkout.Api.Models;
namespace Walley.Checkout.Api.Services;
public interface IOrderService
{
Task<IEnumerable<Order>> GetAllOrdersAsync();
Task<Order?> GetOrderByIdAsync(string id);
Task<Order> CreateOrderAsync(Order order);
}
src/Walley.Checkout.Api/Services/IRefundService.cs
+8
-0
diff --git a/src/Walley.Checkout.Api/Services/IRefundService.cs b/src/Walley.Checkout.Api/Services/IRefundService.cs
new file mode 100644
index 0000000..14c3c95
@@ -0,0 +1,8 @@
using Walley.Checkout.Api.Models;
namespace Walley.Checkout.Api.Services;
public interface IRefundService
{
Task<Refund> ProcessRefundAsync(string orderId, decimal amount, string reason);
}
src/Walley.Checkout.Api/Services/OrderService.cs
+89
-0
diff --git a/src/Walley.Checkout.Api/Services/OrderService.cs b/src/Walley.Checkout.Api/Services/OrderService.cs
new file mode 100644
index 0000000..ea7692c
@@ -0,0 +1,89 @@
using Walley.Checkout.Api.Models;
namespace Walley.Checkout.Api.Services;
public class OrderService : IOrderService
{
private readonly List<Order> _orders = new();
public OrderService()
{
SeedData();
}
public Task<IEnumerable<Order>> GetAllOrdersAsync()
{
return Task.FromResult<IEnumerable<Order>>(_orders);
}
public Task<Order?> GetOrderByIdAsync(string id)
{
var order = _orders.FirstOrDefault(o => o.Id == id);
return Task.FromResult(order);
}
public Task<Order> CreateOrderAsync(Order order)
{
order.Id = $"ORD-{Guid.NewGuid().ToString("N")[..8].ToUpper()}";
order.CreatedAt = DateTime.UtcNow;
order.Status = OrderStatus.Pending;
order.TotalAmount = order.Lines.Sum(l => l.LineTotal);
_orders.Add(order);
return Task.FromResult(order);
}
private void SeedData()
{
_orders.AddRange(new[]
{
new Order
{
Id = "ORD-001",
CustomerName = "Anna Svensson",
CustomerEmail = "anna.svensson@example.com",
Currency = "SEK",
Status = OrderStatus.Completed,
CreatedAt = DateTime.UtcNow.AddDays(-5),
CompletedAt = DateTime.UtcNow.AddDays(-4),
Lines = new List<OrderLine>
{
new() { ProductName = "Wireless Headphones", Quantity = 1, UnitPrice = 899.00m },
new() { ProductName = "USB-C Cable", Quantity = 2, UnitPrice = 149.00m }
},
TotalAmount = 1197.00m
},
new Order
{
Id = "ORD-002",
CustomerName = "Erik Johansson",
CustomerEmail = "erik.j@example.com",
Currency = "SEK",
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow.AddHours(-2),
Lines = new List<OrderLine>
{
new() { ProductName = "Laptop Stand", Quantity = 1, UnitPrice = 549.00m }
},
TotalAmount = 549.00m
},
new Order
{
Id = "ORD-003",
CustomerName = "Maria Lindqvist",
CustomerEmail = "maria.l@example.com",
Currency = "NOK",
Status = OrderStatus.Confirmed,
CreatedAt = DateTime.UtcNow.AddDays(-1),
Lines = new List<OrderLine>
{
new() { ProductName = "Mechanical Keyboard", Quantity = 1, UnitPrice = 1299.00m },
new() { ProductName = "Desk Pad", Quantity = 1, UnitPrice = 349.00m },
new() { ProductName = "Webcam", Quantity = 1, UnitPrice = 799.00m }
},
TotalAmount = 2447.00m
}
});
}
}
src/Walley.Checkout.Api/Services/RefundService.cs
+54
-0
diff --git a/src/Walley.Checkout.Api/Services/RefundService.cs b/src/Walley.Checkout.Api/Services/RefundService.cs
new file mode 100644
index 0000000..3075b62
@@ -0,0 +1,54 @@
using Walley.Checkout.Api.Models;
namespace Walley.Checkout.Api.Services;
public class RefundService : IRefundService
{
private readonly IOrderService _orderService;
public RefundService(IOrderService orderService)
{
_orderService = orderService;
}
/// <summary>
/// Processes a refund for the given order.
/// The refund should only be approved if:
/// - The order exists
/// - The order status is Completed
/// - The refund amount does not exceed the order total
/// </summary>
public async Task<Refund> ProcessRefundAsync(string orderId, decimal amount, string reason)
{
var order = _orderService.GetOrderByIdAsync(orderId);
var refund = new Refund
{
Id = $"REF-{Guid.NewGuid().ToString("N")[..8].ToUpper()}",
OrderId = orderId,
Amount = amount,
Reason = reason
};
if (order == null)
{
refund.Status = RefundStatus.Rejected;
return refund;
}
if (order.Result.Status != OrderStatus.Completed)
{
refund.Status = RefundStatus.Rejected;
return refund;
}
if (amount > order.Result.TotalAmount)
{
refund.Status = RefundStatus.Rejected;
return refund;
}
refund.Status = RefundStatus.Approved;
return refund;
}
}
src/Walley.Checkout.Api/Walley.Checkout.Api.csproj
+13
-0
diff --git a/src/Walley.Checkout.Api/Walley.Checkout.Api.csproj b/src/Walley.Checkout.Api/Walley.Checkout.Api.csproj
new file mode 100644
index 0000000..137bb2c
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>
src/Walley.Checkout.Api/appsettings.Development.json
+8
-0
diff --git a/src/Walley.Checkout.Api/appsettings.Development.json b/src/Walley.Checkout.Api/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
src/Walley.Checkout.Api/appsettings.json
+9
-0
diff --git a/src/Walley.Checkout.Api/appsettings.json b/src/Walley.Checkout.Api/appsettings.json
new file mode 100644
index 0000000..10f68b8
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}