A .NET 8 Web API that takes in messy, real-world purchase and sales exports (Excel / CSV) from a wholesale ERP and turns them into useful analytics: top-selling products, deadstock and per-product profit.
π Live API: https://accountantapi.ahmedeltwab.com/swagger/index.html π§ͺ Sample files:
Examples/. These are fake datasets you can upload right away.
This project began as a technical assessment for a job at a wholesale market chain. I received real sales and purchase exports from their ERP system and was asked to build a system that loads them and answers business questions:
- Upload purchase & sales data from CSV or Excel
- Get the top N products by sales
- Find deadstock (products with low sales)
- View a profit report for each product
- Search for a product by name
I made it to the final phase but wasn't selected. I kept working on the project anyway and used it to practice what I would have done with more time: refactoring toward cleaner design, adding stronger error handling and validation, and finally containerizing and deploying it with a CI/CD pipeline.
The original company files contain real business data, so they are not included. The files in
Examples/are generated fake data with the same key columns.
-
Open the live Swagger UI.
-
Download a sample file from
Examples/:File Use with mock_purchases.csv/.xlsxPOST /api/upload/Purchasemock_sales.csv/.xlsxPOST /api/upload/Sale -
Upload purchases with
POST /api/upload/Purchase:Field Value headerRow1productNameHeaderProduct NamepriceHeaderTotal PricepurchaseFilemock_purchases.csv -
Upload sales with
POST /api/upload/Sale:Field Value headerRow1productNameHeaderProduct NamequantityHeaderQuantitypriceHeaderTotal PricesalesFilemock_sales.csv -
Query the analytics:
GET /GetTopSales/5,GET /GetDeadstock,GET /GetProfit/Milk 1L,GET /GetProduct/Rice
π‘ The sample files contain extra columns (
Transaction ID,Date,Unit Price). The API ignores them on purpose and reads only the columns you name. See Challenge 1 below.
The real files had Arabic headers, report titles and metadata rows above the table, so the header was not on row 1. They also had many columns the analysis didn't need.
Solution: the user tells the API which row holds the header and what the relevant columns are called (headerRow, productNameHeader, quantityHeader, priceHeader). The parser skips to that row, checks that the named columns exist (returning a clear error if they don't), and reads only those columns. As a result, the same endpoint works with any branch's export format and in any language.
The first version took several minutes to load a full sheet of around 1M rows. Changes that helped:
- Batching rows (20,000 per batch) before writing to the database
EFCore.BulkExtensions(SqlBulkCopy) instead of EF Core's change-trackedSaveChanges- An in-memory
HashSetof existing product names, so the database isn't queried for every row AsNoTrackingon read queries, plus pagination on deadstock
Result: a full ~1M-row sheet now loads in about 80 seconds (measured with a Stopwatch in the controller). There is still room to improve this. See What's Next.
The same product can show up many times in one file and across several uploads.
Solution: duplicates are merged. When a product repeats, its values are added to the existing record (both within the current batch and against what's already in the database), so no duplicate rows are created.
The product codes in the purchase file didn't match those in the sales file, and the codes also differ between branches. A code couldn't serve as a reliable key.
Solution: I changed the schema so the product name is the primary key and sales reference products by name. This is visible in the migration history (changeProductID β ProductNamePK).
Early versions relied on exceptions for control flow.
Solution:
- Result pattern (
Result<T>with typedErrorType), so services return success or failure explicitly andResultExtensionsmaps them to the right HTTP status - FluentValidation on the upload DTOs, so bad input is rejected before any processing
- Global error-handling middleware as a safety net for anything unexpected
To demonstrate DevOps skills, I added:
- Dockerfiles for the API and the React frontend, plus
docker-composewith SQL Server (including a health check, so the API waits for the DB) - A GitHub Actions pipeline that builds, pushes the image to Azure Container Registry and deploys to Azure Container Apps on every push to
main - A custom domain:
accountantapi.ahmedeltwab.com
The project follows Clean Architecture: each layer depends only on the layers beneath it, and the domain has no external dependencies.
SRC/
βββ Purchase&Sales_API/ # Controllers, middleware, Result β HTTP mapping
βββ Purchase&Sales_Core/ # Services (one use case per class), DTOs, validators, Result<T>
βββ Purchase&Sales_Domain/ # Entities + repository interfaces
βββ Purchase&Sales_Infrastructure/ # EF Core DbContext, repositories, migrations, bulk ops
βββ frontend/ # React + Vite + Tailwind client (work in progress)
flowchart LR
U["Excel / CSV upload"] --> C["API Controller"]
C --> V["FluentValidation"]
V --> S["Core Service: read named columns, merge duplicates"]
S -->|"batches of 20k"| R["Repository"]
R -->|"BulkExtensions"| DB[("SQL Server")]
S -.->|"Result object"| C
The API hosts a Hangfire server backed by the configured ApplicationDb SQL Server database. Hangfire creates and manages its tables in the HangFire schema. CSV uploads are saved under App_Data/HangfireUploads and processed in the background; the Hangfire job ID is returned to the caller. Docker Compose mounts this directory as a persistent volume. The worker count, queues, dashboard path, upload directory, and storage schema can be changed in SRC/Purchase&Sales_API/appsettings.json.
The Hangfire dashboard is available at /hangfire (or the configured dashboard path). Hangfire's default dashboard authorization permits local requests only; configure an authorization filter before exposing it to remote users. The server is ready to process jobs once the application enqueues or schedules them.
| Method | Endpoint | Fields |
|---|---|---|
POST |
/api/upload/Purchase |
purchaseFile, headerRow, productNameHeader, priceHeader |
POST |
/api/upload/Sale |
salesFile, headerRow, productNameHeader, quantityHeader, priceHeader |
Accepts .csv and .xlsx. Any other format returns 400 with a clear message.
| Method | Endpoint | Description |
|---|---|---|
GET |
/GetTopSales/{n} |
Top n products by sales |
GET |
/GetDeadstock?pageNumber=1&pageSize=5 |
Low-selling products, paginated (max page size 50) |
GET |
/GetProfit/{productName} |
Profit report for one product |
GET |
/GetProduct/{productName} |
Search products by name |
| Category | Technology |
|---|---|
| Backend | ASP.NET Core Web API (.NET 8) |
| Data | Entity Framework Core 8, SQL Server, EFCore.BulkExtensions |
| File parsing | CsvHelper (CSV), EPPlus (Excel) |
| Validation & errors | FluentValidation, Result pattern, global middleware |
| Docs | Swagger / Swashbuckle |
| Frontend | React 19, Vite, Tailwind CSS, Axios (not deployed yet) |
| DevOps | Docker, docker-compose, GitHub Actions, Azure Container Registry, Azure Container Apps |
git clone https://github.com/AhmedMTwab/Purchase-Sales-Analytics-API.git
cd Purchase-Sales-Analytics-API
cp .env.example .env # set SA_PASSWORD
docker compose up --build| Service | URL |
|---|---|
| API (Swagger) | http://localhost:8080/swagger |
| React app | http://localhost:3000 |
SQL Server needs about 30 seconds to start on the first run. Migrations apply automatically.
Prerequisites: .NET 8 SDK, SQL Server, Node.js 20+
# API: set ConnectionStrings:ApplicationDb in appsettings.json first
dotnet run --project "SRC/Purchase&Sales_API"
# Frontend
cd SRC/frontend
npm install
npm run dev- Background jobs for file processing (in progress): return immediately with a job ID and process large files in the background, avoiding HTTP timeouts on 1M-row uploads
- Automated tests: unit tests for the parsing and merge logic, and integration tests for the endpoints (the CI pipeline already has a test step ready)
- Further ingestion speedups: fewer per-row DB lookups during updates, streaming Excel reads
- Authentication, if the API becomes multi-tenant
- Finish and deploy the React frontend
