Grocery POS with BIR VAT Computation
Build a Blazor Server POS that extracts Philippine 12% VAT and saves sales to SQL Express.
Introduction
30 Second Summary
Every grocery receipt you get at the checkout counter breaks down your total into Vatable Sales, VAT Amount, and Total Due. That breakdown is not optional. It is mandated by the Bureau of Internal Revenue (BIR) for every transaction in the Philippines.
In this project, you will build a grocery POS checkout system that extracts 12% VAT from VAT-inclusive prices and displays the BIR-required breakdown on every sale receipt. You will use Blazor Server for the interactive UI, Dapper for database access, and SQL Server Express to persist every transaction.
What You'll Build
You will have a POS terminal running in your browser where you click grocery items, watch them stack up in a live cart, hit Process Sale, and see a receipt with the exact VAT breakdown that BIR requires on every invoice.
By the end of this project, you'll have:
- A product grid showing grocery items with prices that you can click to add to a live cart, with quantity controls and running totals computed via LINQ.
- A Process Sale flow that persists every transaction to SQL Express via Dapper with proper transaction handling, so no sale is ever half-saved.
- A BIR-compliant receipt displaying Vatable Sales, VAT Amount (12%), VAT-Exempt Sales, Zero-Rated Sales, and Total Amount Due, matching what the Bureau of Internal Revenue requires on every invoice.
- Secret Mission: Implement the SC/PWD 20% discount with VAT exemption using the correct BIR formula, the most commonly miscalculated discount in Philippine retail POS systems.
Are there any prerequisites?
You should have the .NET 9 SDK installed, SQL Server Express running, and familiarity with Blazor components and C#. This project scaffolds everything from scratch, so no starter repo is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Getting Philippine VAT computation wrong in a POS system means every invoice you print is non-compliant with BIR regulations. Understanding the tax extraction formula and receipt requirements upfront saves you from retrofitting the entire system later.
Create the Blazor Server Project with Dapper
Every POS system starts with a running application shell and a way to talk to a database. Without these two pieces, you cannot display products, save sales, or compute VAT.
In this step, you will scaffold a Blazor Web App using the .NET CLI, add Dapper and the SQL Server client as NuGet packages, and wire up the connection string so your app can reach SQL Express the moment you need it.
In this step, get ready to:
- Scaffold the GroceryPOS Blazor Web App project.
- Install Dapper and Microsoft.Data.SqlClient via NuGet.
- Configure the SQL Express connection string and verify the app runs.
Scaffold the Blazor Web App
The Blazor Web App template gives you a server-rendered app with interactive components out of the box. You will use the -o flag to create the project in a new folder called GroceryPOS.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Blazor Web App project by running this command:
dotnet new blazor -o GroceryPOS
You should see a message confirming the template was created successfully.
- Move into the new project folder by running:
cd GroceryPOS
- Confirm the project files exist by running:
ls
You should see files including Program.cs, GroceryPOS.csproj, and folders like Components and Properties.
What did this command do?
The dotnet new blazor command scaffolded a Blazor Web App with interactive server-side rendering enabled by default. The -o GroceryPOS flag created a new folder with that name and placed all the template files inside it.
Seeing 'No templates found'?
Make sure your .NET 9 SDK is installed correctly. Run dotnet --version and confirm it shows 9.x.x. If you see an older version, update your SDK from the official .NET download page.
help me troubleshoot dotnet new blazor not finding the template
Add NuGet packages for Dapper and SQL Server
Dapper is a lightweight micro-ORM that maps SQL query results directly to C# objects. Microsoft.Data.SqlClient is the official ADO.NET provider for SQL Server.
- Install Dapper by running this command:
dotnet add package Dapper --version 2.1.79
You should see output confirming the package was added to the project.
- Install the SQL Server client library by running:
dotnet add package Microsoft.Data.SqlClient --version 5.2.3
You should see similar output confirming Microsoft.Data.SqlClient was added successfully.
Why pin the versions?
Pinning package versions ensures every learner gets the same behaviour. Dapper 2.1.79 is the latest stable release. Microsoft.Data.SqlClient 5.2.3 is the latest patch in the 5.x line and is broadly compatible with SQL Express configurations.
Package install failed?
Check that you are inside the GroceryPOS folder (where the .csproj file lives). Run ls and confirm you see GroceryPOS.csproj in the output. Also confirm you have an internet connection, as NuGet needs to download the packages.
help me fix a NuGet package install failure
Configure the connection string and verify the app runs
Your app needs to know how to connect to SQL Express. The standard place to store this is in appsettings.json.
- Open the GroceryPOS folder in your code editor.
- Open the file appsettings.json in the project root.
- Replace the entire contents of appsettings.json with the following:
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=GroceryPOS;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
What does this connection string do?
Server=.\\SQLEXPRESS tells the app to connect to the SQL Express instance on your local machine. Database=GroceryPOS names the database you will create in the next step.
Trusted_Connection=True uses your Windows credentials instead of a username and password. TrustServerCertificate=True bypasses certificate validation for local development.
- Save appsettings.json.
Before you run the app, what do you expect to see in the browser? The database does not exist yet, but the app should still start because it has not tried to query anything.
- Start the app with the HTTPS launch profile by running:
dotnet watch -lp https
Your terminal will display the URL where the app is running. Look for a line showing https://localhost:{port} in the output.
- Open your browser and navigate to the HTTPS URL shown in the terminal output.
You should see the default Blazor welcome page with a "Hello, world!" heading and a navigation sidebar.
Browser shows a certificate warning?
This is normal for local development HTTPS. Click Advanced and then Proceed (or Accept the Risk in Firefox) to continue to the page. Alternatively, run dotnet dev-certs https --trust once to trust the development certificate permanently.
help me fix the HTTPS certificate warning for my local Blazor app
Stop the app when you're done checking
Press Ctrl+C in the terminal to stop the development server. You will restart it later when there is something new to see.
✔️ Awesome, I've got everything!
Great. Double check you have saved appsettings.json and that the app started without errors.
ⓧ I'd like to double check the full code
Here is the complete appsettings.json you should have at this point:
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=GroceryPOS;Trusted_Connection=True;TrustServerCertificate=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Your Blazor Web App is running and wired up to reach SQL Express. Next up, you will create the database schema with the tables needed to store products and sales.
Build the Database Schema
Your Blazor app is running, but it has nowhere to store products or sales data. Without a database, you cannot load grocery items or save completed transactions.
In this step, you will create the SQL Server database with tables designed for BIR compliance from the start. Baking in fields like InvoiceNo, VatableSales, and TIN now means you will not have to retrofit them later when you add invoicing.
In this step, get ready to:
- Create the GroceryPOS database with Products, Sales, and SaleItems tables.
- Seed sample grocery products with VAT-inclusive prices.
- Create the Dapper repository and register it via dependency injection.
Create the GroceryPOS database and core tables
You need four tables: Categories for product grouping, Products for the grocery items, Sales for completed transactions with BIR fields, and SaleItems for individual line items in each sale.
- Create a Database folder in your project and a schema.sql file inside it by running these commands:
mkdir Database
touch Database/schema.sql
- Confirm the file exists by running:
ls Database/
You should see schema.sql listed in the output.
- Open Database/schema.sql and paste the following to create the database, the Categories table, and the Products table:
CREATE DATABASE GroceryPOS;
GO
USE GroceryPOS;
GO
CREATE TABLE Categories (
CategoryId INT IDENTITY(1,1) PRIMARY KEY,
CategoryName NVARCHAR(100) NOT NULL
);
CREATE TABLE Products (
ProductId INT IDENTITY(1,1) PRIMARY KEY,
ProductCode NVARCHAR(20) NOT NULL,
ProductName NVARCHAR(200) NOT NULL,
Category NVARCHAR(100) NOT NULL,
Price DECIMAL(18,2) NOT NULL,
StockQty INT NOT NULL DEFAULT 0,
IsVatable BIT NOT NULL DEFAULT 1
);
What does this code do?
CREATE DATABASE GroceryPOS creates a new database on your SQL Express instance. GO tells sqlcmd to execute the preceding batch before continuing.
The Products table stores each grocery item with a Price column for the VAT-inclusive shelf price and an IsVatable flag to mark whether the item carries VAT.
- Below the Products table in the same file, add the Sales and SaleItems tables:
CREATE TABLE Sales (
SaleId INT IDENTITY(1,1) PRIMARY KEY,
InvoiceNo NVARCHAR(50) NOT NULL,
SaleDate DATETIME NOT NULL DEFAULT GETDATE(),
CustomerType NVARCHAR(20) NOT NULL DEFAULT 'Regular',
VatableSales DECIMAL(18,2) NOT NULL DEFAULT 0,
VatAmount DECIMAL(18,2) NOT NULL DEFAULT 0,
VatExemptSales DECIMAL(18,2) NOT NULL DEFAULT 0,
ZeroRatedSales DECIMAL(18,2) NOT NULL DEFAULT 0,
TotalDue DECIMAL(18,2) NOT NULL DEFAULT 0,
DiscountAmount DECIMAL(18,2) NOT NULL DEFAULT 0,
-- BIR fields for future use (Project 2)
TIN NVARCHAR(20) NULL,
MIN_No NVARCHAR(50) NULL,
SerialNo NVARCHAR(50) NULL
);
CREATE TABLE SaleItems (
SaleItemId INT IDENTITY(1,1) PRIMARY KEY,
SaleId INT NOT NULL FOREIGN KEY REFERENCES Sales(SaleId),
ProductId INT NOT NULL FOREIGN KEY REFERENCES Products(ProductId),
ProductName NVARCHAR(200) NOT NULL,
UnitPrice DECIMAL(18,2) NOT NULL,
Quantity INT NOT NULL,
LineTotal DECIMAL(18,2) NOT NULL
);
Why does the Sales table have so many columns?
The BIR requires every POS invoice to show a breakdown: Vatable Sales, VAT Amount, VAT-Exempt Sales, Zero-Rated Sales, and Total Due. Storing each value in its own column means the receipt can pull them directly without recalculating.
The TIN, MIN_No, and SerialNo columns are NULL for now. They hold the Taxpayer Identification Number and Machine Identification Number that BIR accreditation requires on printed receipts.
Seed sample grocery products
The tables are defined but empty. You need sample products with real Philippine grocery prices so you can test the POS immediately after the schema runs.
- At the bottom of Database/schema.sql, add these INSERT statements:
-- Seed sample grocery products (all prices VAT-inclusive)
INSERT INTO Products (ProductCode, ProductName, Category, Price, StockQty, IsVatable) VALUES
('GRC-001', 'Rice 1kg', 'Grains', 55.00, 100, 1),
('GRC-002', 'Canned Sardines', 'Canned Goods', 28.00, 200, 1),
('GRC-003', 'Cooking Oil 1L', 'Cooking Essentials', 95.00, 80, 1),
('GRC-004', 'Instant Noodles', 'Noodles', 9.50, 500, 1),
('GRC-005', 'Fresh Eggs 1 Tray', 'Dairy & Eggs', 240.00, 50, 1),
('GRC-006', 'Sugar 1kg', 'Baking', 75.00, 120, 1),
('GRC-007', 'Soy Sauce 1L', 'Condiments', 45.00, 150, 1),
('GRC-008', 'Laundry Detergent 1kg', 'Household', 125.00, 60, 1);
GO
Why are these prices VAT-inclusive?
In Philippine retail, every shelf price already includes the 12% VAT. Customers pay the displayed price. The POS must extract the VAT from that price (not add it on top) when generating the receipt breakdown.
- Save Database/schema.sql.
- Run the full script against your SQL Express instance by executing:
sqlcmd -S .\SQLEXPRESS -E -i Database/schema.sql
What does this command do?
-S .\SQLEXPRESS points to your local SQL Express instance. -E uses Windows Authentication so no password is needed. -i specifies the input script file to execute.
Before you run the next command, what do you expect to see if the seed data was inserted correctly?
- Verify the products were inserted by running this query:
sqlcmd -S .\SQLEXPRESS -E -d GroceryPOS -Q "SELECT ProductName, Price FROM Products"
You should see all 8 grocery items listed with their VAT-inclusive prices: Rice 1kg at 55.00, Canned Sardines at 28.00, Cooking Oil 1L at 95.00, and so on.
Not seeing the products?
- If you see "Cannot open database 'GroceryPOS'", the CREATE DATABASE statement may have failed. Check that SQL Server Express is running in Services (Windows) or via net start MSSQL$SQLEXPRESS.
- If you see a syntax error, make sure each GO statement is on its own line with no leading spaces.
- If the database already exists from a previous attempt, add DROP DATABASE IF EXISTS GroceryPOS; before the CREATE line and re-run the script.
help me troubleshoot my sqlcmd script execution
✔️ Awesome, I've got everything!
Great. Make sure Database/schema.sql is saved with all the CREATE TABLE and INSERT statements.
ⓧ I'd like to double check the full code
Your schema.sql file is longer than 30 lines, so confirm these three sections are present in order:
- CREATE DATABASE and USE statements, then the Categories table, then the Products table (the first chunk you wrote).
- Sales table with all BIR columns (including TIN, MIN_No, SerialNo), then the SaleItems table with foreign keys (the second chunk).
- INSERT INTO Products with all 8 grocery items ending with GO (the seed data chunk).
Create the Dapper repository and register it via DI
Now you need C# classes that map to the database tables, plus a repository that uses Dapper to query the Products table.
- Create a Models folder by running:
mkdir Models
- Create Models/Product.cs with the following content:
namespace GroceryPOS.Models;
// Represents a grocery item in the Products table
public class Product
{
public int ProductId { get; set; }
public string ProductCode { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Price { get; set; }
public int StockQty { get; set; }
public bool IsVatable { get; set; } = true;
}
What does this code do?
This class mirrors the columns in your Products table. Dapper maps SQL query results to these properties by matching column names to property names automatically.
- Create Models/CartItem.cs to represent an item in the shopping cart:
namespace GroceryPOS.Models;
// Represents a product added to the in-memory shopping cart
public class CartItem
{
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Quantity { get; set; } = 1;
public bool IsVatable { get; set; } = true;
// Computed property: price times quantity
public decimal LineTotal => Price * Quantity;
}
- Save both files and confirm the models exist by running:
ls Models/
You should see Product.cs and CartItem.cs listed.
- Create Models/Sale.cs to represent a completed transaction:
namespace GroceryPOS.Models;
// Represents a completed sale stored in the Sales table
public class Sale
{
public int SaleId { get; set; }
public string InvoiceNo { get; set; } = string.Empty;
public DateTime SaleDate { get; set; } = DateTime.Now;
public string CustomerType { get; set; } = "Regular";
public decimal VatableSales { get; set; }
public decimal VatAmount { get; set; }
public decimal VatExemptSales { get; set; }
public decimal ZeroRatedSales { get; set; }
public decimal TotalDue { get; set; }
}
- Create Models/SaleItem.cs to represent a single line item within a sale:
namespace GroceryPOS.Models;
// Represents one line item in a sale (links to SaleItems table)
public class SaleItem
{
public int SaleItemId { get; set; }
public int SaleId { get; set; }
public int ProductId { get; set; }
public string ProductName { get; set; } = string.Empty;
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
public decimal LineTotal { get; set; }
}
- Save both files and confirm all four models exist:
ls Models/
You should see CartItem.cs, Product.cs, Sale.cs, and SaleItem.cs listed.
Now you need the repository layer that queries the database using Dapper.
- Create a Repositories folder by running:
mkdir Repositories
- Create Repositories/IProductRepository.cs with the interface definition:
namespace GroceryPOS.Repositories;
using GroceryPOS.Models;
// Contract for product data access
public interface IProductRepository
{
Task<IEnumerable<Product>> GetAllAsync();
Task<Product?> GetByIdAsync(int productId);
}
What does this code do?
The interface defines two methods: GetAllAsync() to load every product for the grid, and GetByIdAsync() to fetch a single product by its ID. Programming to an interface lets you swap the implementation later without touching the components that consume it.
- Create Repositories/ProductRepository.cs with the Dapper implementation:
namespace GroceryPOS.Repositories;
using Dapper;
using GroceryPOS.Models;
using Microsoft.Data.SqlClient;
public class ProductRepository : IProductRepository
{
private readonly string _connectionString;
public ProductRepository(string connectionString)
{
_connectionString = connectionString;
}
// Fetch all products for the POS grid
public async Task<IEnumerable<Product>> GetAllAsync()
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<Product>(
"SELECT ProductId, ProductCode, ProductName, Category, Price, StockQty, IsVatable FROM Products");
}
// Fetch a single product by ID
public async Task<Product?> GetByIdAsync(int productId)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryFirstOrDefaultAsync<Product>(
"SELECT ProductId, ProductCode, ProductName, Category, Price, StockQty, IsVatable FROM Products WHERE ProductId = @ProductId",
new { ProductId = productId });
}
}
What does this code do?
Each method creates a new SqlConnection using the connection string, then calls a Dapper extension method. QueryAsync<Product> maps each row in the result set to a Product object by matching column names to property names.
The using keyword ensures the connection is disposed and returned to the pool when the method completes.
- Save both repository files.
The last piece is registering the repository in Program.cs so Blazor components can inject it.
- Open Program.cs and replace its contents with the following:
using GroceryPOS.Components;
using GroceryPOS.Repositories;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Read the SQL Express connection string from config
string connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
// Register the product repository as a singleton
builder.Services.AddSingleton<IProductRepository>(new ProductRepository(connectionString));
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
What does this code do?
GetConnectionString("DefaultConnection") reads the SQL Express connection string you added to appsettings.json in Step 1.
AddSingleton<IProductRepository> registers a single instance of ProductRepository that any Blazor component can request via @inject. The connection string is passed in through the constructor.
- Save Program.cs.
- Verify the project compiles with all new classes by running:
dotnet build
You should see Build succeeded with 0 errors in the output.
Build failed with errors?
- If you see "The type or namespace 'Models' does not exist", check that each model file starts with namespace GroceryPOS.Models; (not a different namespace).
- If you see "The type or namespace 'Repositories' does not exist", verify ProductRepository.cs starts with namespace GroceryPOS.Repositories;.
- If you see errors about Dapper or SqlConnection, confirm the NuGet packages were added in Step 1 by running dotnet list package.
help me fix my dotnet build errors for the GroceryPOS repository classes
✔️ Awesome, I've got everything!
Great. Confirm you have saved all files in Models/ and Repositories/, plus Program.cs.
ⓧ I'd like to double check the full code
Here is the full Repositories/ProductRepository.cs for comparison:
namespace GroceryPOS.Repositories;
using Dapper;
using GroceryPOS.Models;
using Microsoft.Data.SqlClient;
public class ProductRepository : IProductRepository
{
private readonly string _connectionString;
public ProductRepository(string connectionString)
{
_connectionString = connectionString;
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryAsync<Product>(
"SELECT ProductId, ProductCode, ProductName, Category, Price, StockQty, IsVatable FROM Products");
}
public async Task<Product?> GetByIdAsync(int productId)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QueryFirstOrDefaultAsync<Product>(
"SELECT ProductId, ProductCode, ProductName, Category, Price, StockQty, IsVatable FROM Products WHERE ProductId = @ProductId",
new { ProductId = productId });
}
}
Before you run the app, do you think it will start successfully now that the repository classes exist and are registered?
- Confirm the full app runs by executing:
dotnet run
The app should start and show the listening URL. Press Ctrl+C to stop it once you confirm it launches.
App crashes on startup?
- If you see "Connection string 'DefaultConnection' not found", make sure appsettings.json has the ConnectionStrings section with DefaultConnection defined exactly as shown in Step 1.
- If you see a "port already in use" error, another instance of the app may still be running. Close any other terminal windows running dotnet run and try again.
help me troubleshoot my GroceryPOS Blazor app startup failure
Your database is ready and the repository can query it. Next up, you will build the POS page with a product grid and a live cart that uses LINQ to compute totals in real time.
Display Products and Build the Cart
Your database is stocked with grocery products and the repository is ready to serve them up. Now you need a way for the cashier to actually see those products and build an order.
In this step, you'll create the POS page with a clickable product grid and a live cart sidebar. LINQ handles all the cart math: finding duplicates, computing line totals, and summing the grand total.
In this step, get ready to:
- Display all grocery products in a Bootstrap card grid.
- Build an in-memory cart with LINQ-powered totals.
- Add quantity adjustment and item removal controls.
Create the POS page with the product grid
The POS page is a Blazor component that loads products from the database and renders them as clickable cards. You'll use @rendermode InteractiveServer so the page responds to clicks without a full page reload.
- Create a new file at Components/Pages/Pos.razor inside your GroceryPOS project folder.
- Paste the following markup as the top section of the file:
@page "/pos"
@rendermode InteractiveServer
@using GroceryPOS.Models
@using GroceryPOS.Repositories
@inject IProductRepository ProductRepo
<PageTitle>Grocery POS</PageTitle>
<div class="container-fluid">
<div class="row">
<div class="col-md-8">
<h4>Products</h4>
<div class="row">
@foreach (var product in products)
{
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h6 class="card-title">@product.ProductName</h6>
<p class="card-text text-success fw-bold">@product.Price.ToString("N2")</p>
<button class="btn btn-primary btn-sm" @onclick="() => AddToCart(product)">
Add
</button>
</div>
</div>
</div>
}
</div>
</div>
What does this markup do?
The @page "/pos" directive registers this component at the /pos URL. The @rendermode InteractiveServer directive enables real-time interactivity over a SignalR connection.
The @inject line asks the DI container for your ProductRepository so the component can query the database. Each product renders as a Bootstrap card with an Add button that calls AddToCart.
- Below the product grid markup, close the outer row and container-fluid divs, leave a blank line, then paste the @code block:
</div>
</div>
@code {
private List<Product> products = new();
private List<CartItem> cart = new();
protected override async Task OnInitializedAsync()
{
products = (await ProductRepo.GetAllAsync()).ToList();
}
private void AddToCart(Product product)
{
var existing = cart.FirstOrDefault(x => x.ProductId == product.ProductId);
if (existing != null)
{
existing.Quantity++;
}
else
{
cart.Add(new CartItem
{
ProductId = product.ProductId,
ProductName = product.ProductName,
Price = product.Price,
Quantity = 1,
IsVatable = product.IsVatable
});
}
}
}
What does the code block do?
OnInitializedAsync fetches all products from SQL Express via Dapper when the page loads.
AddToCart uses LINQ's .FirstOrDefault() to check if the product is already in the cart. If it is, the quantity increments. If not, a new CartItem is added.
- Save Pos.razor.
- Run the app by running this command in your terminal:
dotnet run
- Open your browser and navigate to https://localhost:5001/pos.
You should see a grid of 8 grocery product cards with names, prices, and Add buttons. Clicking Add won't show anything visible yet because there's no cart UI.
Don't see any products?
- Check that your SQL Express instance is running and the GroceryPOS database has seeded products.
- Verify the connection string in appsettings.json matches your SQL Express instance name.
- Confirm you have @using GroceryPOS.Models at the top of the file.
Why aren't my products loading on the POS page?
Implement the in-memory cart with LINQ
The product grid works, but clicking Add does nothing visible. You need a cart sidebar that shows selected items with running totals computed by LINQ.
- Stop the running app with Ctrl+C in the terminal.
- In Pos.razor, find the closing </div> of col-md-8 (line 29 of the markup). Add the following cart sidebar directly below it, before the closing </div> of the row:
<div class="col-md-4">
<h4>Cart</h4>
@if (cart.Any())
{
<ul class="list-group mb-3">
@foreach (var item in cart)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>@item.ProductName</strong><br />
<small>@item.Price.ToString("N2") x @item.Quantity</small>
</div>
<div>
<span class="badge bg-dark">@item.LineTotal.ToString("N2")</span>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => DecreaseQty(item)">-</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => IncreaseQty(item)">+</button>
<button class="btn btn-sm btn-outline-danger ms-1" @onclick="() => RemoveItem(item)">X</button>
</div>
</li>
}
</ul>
- Continue pasting directly below the </ul> closing tag:
<div class="card mb-3">
<div class="card-body">
<h5>Total: P @CartTotal.ToString("N2")</h5>
</div>
</div>
}
else
{
<p class="text-muted">No items in cart.</p>
}
</div>
What does the cart sidebar do?
The @if (cart.Any()) check shows the cart list only when items exist. Each item displays its name, unit price, quantity, and line total as a badge.
The Total card at the bottom uses CartTotal which you'll wire up next. The +/- and X buttons reference methods you'll add in the next substep.
- Now add the CartTotal computed property inside the @code block. Find the line private List<CartItem> cart = new(); and add this directly below it:
private decimal CartTotal => cart.Sum(x => x.LineTotal);
How does CartTotal work?
LINQ's .Sum() iterates every CartItem and adds up the LineTotal property (which is Price * Quantity). Because it's a computed property (=>), it recalculates every time Blazor re-renders.
Add the quantity control methods
The cart sidebar references three methods that don't exist yet. Without them the project won't compile.
- In Pos.razor, add the following methods inside the @code block, below the closing brace of AddToCart:
private void IncreaseQty(CartItem item) => item.Quantity++;
private void DecreaseQty(CartItem item)
{
if (item.Quantity > 1)
item.Quantity--;
else
cart.Remove(item);
}
private void RemoveItem(CartItem item) => cart.Remove(item);
What do these methods do?
IncreaseQty bumps the quantity by one. DecreaseQty reduces it, but if it hits zero the item is removed entirely. RemoveItem deletes the item from the cart regardless of quantity.
Blazor automatically re-renders the component after each method runs, so the cart sidebar updates instantly.
- Save Pos.razor.
Before you run the app, think about this: if you click Rice (P55.00) and Cooking Oil (P95.00), what total should the cart show?
- Start the app by running:
dotnet run
- Navigate to https://localhost:5001/pos in your browser.
- Click the Add button on Rice, then click Add on Cooking Oil.
The cart sidebar appears with both items, their line totals, and a grand total of P150.00. Try clicking the + button on Rice. The quantity jumps to 2 and the total updates to P205.00 instantly.
Cart not showing or getting a build error?
- If you see a compilation error mentioning DecreaseQty or IncreaseQty, make sure all three methods are inside the @code { } block.
- If the cart sidebar doesn't appear after clicking Add, confirm the col-md-4 div is inside the same row div as col-md-8.
- If the total shows 0, check that CartTotal is placed after the cart declaration and uses => (not =).
Help me fix my Blazor cart sidebar that isn't rendering or calculating totals correctly.
✔️ Awesome, I've got everything!
Great. Double check you've saved Pos.razor and your cart is updating in real time.
ⓧ I'd like to double check the full code
Your Pos.razor file should contain these sections in order. Scroll up and confirm each chunk is present:
- Directives: @page, @rendermode, @using lines, and @inject IProductRepository ProductRepo.
- Product grid: container-fluid with a col-md-8 containing the @foreach loop of product cards.
- Cart sidebar: col-md-4 with @if (cart.Any()), the list group with +/- and X buttons, Total card, and the else clause.
- @code block: products list, cart list, CartTotal property, OnInitializedAsync, AddToCart, IncreaseQty, DecreaseQty, RemoveItem.
Your POS page has a working product grid and a live cart with real-time totals. Next, you'll wire up the Process Sale button to persist completed transactions to the database.
Process a Sale (Naive - No VAT Breakdown)
Your POS system can display products and accumulate them in a cart. The next logical step is saving a completed sale to the database so you have a permanent record of every transaction.
In this step, you will wire up a "Process Sale" button that persists the cart to SQL Express and displays a receipt. The approach is deliberately naive: it saves only the lump-sum total without any VAT breakdown. That gap becomes obvious the moment you inspect the saved record.
In this step, get ready to:
- Create a sale repository that inserts sales and line items within a database transaction.
- Wire up a Process Sale button that saves the cart and shows a receipt modal.
- Observe the problem: the receipt shows only a total with no VAT breakdown, and the database columns for VAT are all zeros.
Create the SaleRepository with an InsertSaleAsync method
The product repository handles reads. Now you need a separate repository dedicated to writing sales data. It will insert into both the Sales and SaleItems tables inside a single Dapper transaction so either both succeed or neither does.
- Create a new file Repositories/ISaleRepository.cs and add the interface:
namespace GroceryPOS.Repositories;
using GroceryPOS.Models;
public interface ISaleRepository
{
Task<int> InsertSaleAsync(Sale sale, IEnumerable<SaleItem> items);
Task<string> GenerateInvoiceNoAsync();
}
What does this interface define?
- InsertSaleAsync takes a Sale object and its line items, saves them atomically, and returns the new SaleId.
- GenerateInvoiceNoAsync produces a sequential invoice number in the format INV-yyyyMMdd-00001.
- Create a new file Repositories/SaleRepository.cs and add the class shell with the invoice-number generator:
namespace GroceryPOS.Repositories;
using Dapper;
using GroceryPOS.Models;
using Microsoft.Data.SqlClient;
public class SaleRepository : ISaleRepository
{
private readonly string _connectionString;
public SaleRepository(string connectionString)
{
_connectionString = connectionString;
}
public async Task<string> GenerateInvoiceNoAsync()
{
using var connection = new SqlConnection(_connectionString);
return await GenerateInvoiceNoAsync(connection, null);
}
private async Task<string> GenerateInvoiceNoAsync(SqlConnection connection, System.Data.IDbTransaction? transaction)
{
int count = await connection.ExecuteScalarAsync<int>(
"SELECT COUNT(*) + 1 FROM Sales", transaction: transaction);
return $"INV-{DateTime.Now:yyyyMMdd}-{count:D5}";
}
}
Why two GenerateInvoiceNoAsync overloads?
The public overload creates its own connection for standalone use. The private overload accepts an existing connection and transaction so InsertSaleAsync can generate the invoice number inside the same transaction that saves the sale. This avoids duplicate numbers under concurrent inserts.
- Verify both files compile by running:
dotnet build
You should see Build succeeded with no errors.
Seeing a compile error?
- Make sure ISaleRepository.cs is in the Repositories folder and uses the namespace GroceryPOS.Repositories.
- Confirm the using GroceryPOS.Models; line is present in both files.
help me fix a compile error in my SaleRepository
Now add the main method that saves a sale. This opens a connection, begins a transaction, inserts the Sale record, then inserts each SaleItem.
- In SaleRepository.cs, add the first part of InsertSaleAsync above the GenerateInvoiceNoAsync public method. This handles connection setup and the Sale INSERT:
public async Task<int> InsertSaleAsync(Sale sale, IEnumerable<SaleItem> items)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
string invoiceNo = await GenerateInvoiceNoAsync(connection, transaction);
sale.InvoiceNo = invoiceNo;
int saleId = await connection.QuerySingleAsync<int>(
@"INSERT INTO Sales (InvoiceNo, SaleDate, CustomerType, VatableSales, VatAmount, VatExemptSales, ZeroRatedSales, TotalDue)
VALUES (@InvoiceNo, @SaleDate, @CustomerType, @VatableSales, @VatAmount, @VatExemptSales, @ZeroRatedSales, @TotalDue);
SELECT CAST(SCOPE_IDENTITY() AS INT);",
sale, transaction);
- Immediately below that code (still inside the try block), add the SaleItems loop and error handling to complete the method:
foreach (var item in items)
{
item.SaleId = saleId;
await connection.ExecuteAsync(
@"INSERT INTO SaleItems (SaleId, ProductId, ProductName, UnitPrice, Quantity, LineTotal)
VALUES (@SaleId, @ProductId, @ProductName, @UnitPrice, @Quantity, @LineTotal)",
item, transaction);
}
transaction.Commit();
return saleId;
}
catch
{
transaction.Rollback();
throw;
}
}
What does InsertSaleAsync do?
- It wraps both INSERTs in a single SQL transaction. If either fails, transaction.Rollback() undoes everything so you never end up with a Sale row that has no SaleItem children.
- SCOPE_IDENTITY() retrieves the auto-generated SaleId so each SaleItem can reference its parent.
- Notice VatableSales and VatAmount are passed through as whatever the caller provides. Right now that will be zero.
- Now register the sale repository in Program.cs. Find this line:
builder.Services.AddSingleton<IProductRepository>(new ProductRepository(connectionString));
- Add the following line directly below it:
builder.Services.AddSingleton<ISaleRepository>(new SaleRepository(connectionString));
- Save Program.cs and SaleRepository.cs, then confirm everything compiles:
dotnet build
You should see Build succeeded again. The repository is ready but nothing calls it yet.
Build fails after adding InsertSaleAsync?
- Check that both code snippets for InsertSaleAsync are inside the class body and that the try/catch braces are balanced.
- Ensure the method sits above the public GenerateInvoiceNoAsync() method and the closing brace of the class.
help me fix a compile error in InsertSaleAsync
Wire up the Process Sale button
The repository can persist sales. Now you need to connect it to the UI so clicking a button saves the current cart and shows a receipt.
- In Pos.razor, find this line at the top:
@inject IProductRepository ProductRepo
- Add this new inject line directly below it:
@inject ISaleRepository SaleRepo
- Next, find the closing </div> of the card that shows the cart total. Add a Process Sale button directly below it (still inside the @if (cart.Any()) block):
<button class="btn btn-success w-100" @onclick="ProcessSale">Process Sale</button>
- In the @code block, find the existing field declarations. Replace them with these updated declarations that include new state variables:
private List<Product> products = new();
private List<CartItem> cart = new();
private bool showReceipt = false;
private decimal lastTotal = 0;
private string lastInvoiceNo = string.Empty;
- Below the RemoveItem method, add the ProcessSale method that builds the Sale object and saves it:
private async Task ProcessSale()
{
if (!cart.Any()) return;
var sale = new Sale
{
SaleDate = DateTime.Now,
CustomerType = "Regular",
VatableSales = 0,
VatAmount = 0,
VatExemptSales = 0,
ZeroRatedSales = 0,
TotalDue = CartTotal
};
var saleItems = cart.Select(c => new SaleItem
{
ProductId = c.ProductId,
ProductName = c.ProductName,
UnitPrice = c.Price,
Quantity = c.Quantity,
LineTotal = c.LineTotal
}).ToList();
await SaleRepo.InsertSaleAsync(sale, saleItems);
lastInvoiceNo = sale.InvoiceNo;
lastTotal = CartTotal;
cart.Clear();
showReceipt = true;
}
What does ProcessSale do?
- It builds a Sale object with only TotalDue populated. VatableSales and VatAmount are hardcoded to 0. This is the naive part.
- LINQ's .Select() projects each CartItem into a SaleItem for database storage.
- After saving, it captures the invoice number and total, clears the cart, and triggers the receipt modal.
- Add the CloseReceipt method below ProcessSale:
private void CloseReceipt()
{
showReceipt = false;
}
- Finally, add the receipt modal markup. Place it after the closing </div> of container-fluid and before the @code block:
@if (showReceipt)
{
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Sale Complete - @lastInvoiceNo</h5>
</div>
<div class="modal-body font-monospace">
<p class="fw-bold fs-5">TOTAL DUE: P @lastTotal.ToString("N2")</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" @onclick="CloseReceipt">New Transaction</button>
</div>
</div>
</div>
</div>
}
What does this modal show?
It displays a Bootstrap modal with the invoice number and the total amount due. That is all it shows. There is no Vatable Sales line, no VAT Amount line, no breakdown of any kind. This is the gap you will fix in the next step.
- Save Pos.razor.
Before you run it, think: what do you expect the receipt to show when you process a sale? Will the database have a complete VAT breakdown?
- Run the application:
dotnet run
- Open your browser to https://localhost:5001/pos.
- Click Add on Rice 1kg (P55.00) and Cooking Oil 1L (P95.00).
- Click Process Sale.
A modal appears showing TOTAL DUE: P 150.00 and an invoice number. That is the only information on the receipt. No Vatable Sales. No VAT Amount.
- Verify the database record by running this query in SSMS or sqlcmd:
SELECT InvoiceNo, VatableSales, VatAmount, TotalDue FROM Sales;
You will see VatableSales = 0.00 and VatAmount = 0.00 while TotalDue = 150.00. This is the problem. BIR requires every POS invoice to show the full VAT breakdown, not just a lump sum.
Process Sale button not appearing?
- The button only renders when the cart has items. Make sure you added it inside the @if (cart.Any()) block, after the Total card's closing </div>.
- Confirm @inject ISaleRepository SaleRepo is present at the top of Pos.razor. A missing inject causes a runtime error.
- Check the browser console (F12) for connection errors to SQL Express.
help me debug why Process Sale is not working
✔️ Awesome, I've got everything!
Great. Double check you have saved all files: ISaleRepository.cs, SaleRepository.cs, Program.cs, and Pos.razor.
ⓧ I'd like to double check the full code
Scroll up and confirm each piece is present in order:
- ISaleRepository.cs with InsertSaleAsync and GenerateInvoiceNoAsync signatures.
- SaleRepository.cs with the class shell, both GenerateInvoiceNoAsync overloads, and the full InsertSaleAsync method (connection, transaction, Sale INSERT, SaleItems loop, commit/rollback).
- Program.cs with the ISaleRepository singleton registration below the product repository line.
- Pos.razor with @inject ISaleRepository SaleRepo, the Process Sale button, state variables (showReceipt, lastTotal, lastInvoiceNo), ProcessSale method, CloseReceipt method, and the receipt modal.
Your POS can now save transactions and show a receipt. But the receipt is just a lump sum. In the next step, you will implement the correct Philippine VAT extraction formula so every sale displays Vatable Sales, VAT Amount, and the full BIR-mandated breakdown.
Implement BIR-Compliant VAT Extraction
In the previous step, you processed a sale and saw the receipt. It showed a lump-sum total and nothing else.
That is a compliance problem. The BIR requires every POS invoice in the Philippines to show a VAT breakdown: Vatable Sales, VAT Amount (12%), VAT-Exempt Sales, Zero-Rated Sales, and Total Amount Due. A receipt without these fields is not a valid invoice. In this step, you will build a VAT calculator service that extracts 12% from VAT-inclusive prices and wire it into the checkout flow.
In this step, get ready to:
- Create a VatCalculatorService that extracts 12% VAT from VAT-inclusive prices.
- Wire the calculator into the sale processing flow and persist VAT fields to SQL Express.
- Update the receipt modal to display the BIR-mandated breakdown.
Create the VatCalculator service
The VAT extraction formula is the core of BIR-compliant POS receipts. Philippine retail prices are VAT-inclusive by convention. To extract the 12% VAT you divide the total by 1.12 to get Vatable Sales, then subtract to get the VAT Amount.
Why divide by 1.12 instead of multiplying by 0.12?
Philippine shelf prices already include 12% VAT baked in. A P112 item is really P100 in goods plus P12 in tax. Multiplying P112 by 0.12 gives P13.44, which is wrong. Dividing P112 by 1.12 correctly recovers the P100 base, and subtracting gives the correct P12 VAT.
You need a model to hold the breakdown values before you can build the calculator.
- Create a new file Models/VatBreakdown.cs in your GroceryPOS project and paste this code:
namespace GroceryPOS.Models;
public class VatBreakdown
{
public decimal GrossAmount { get; set; }
public decimal VatableSales { get; set; }
public decimal VatAmount { get; set; }
public decimal VatExemptSales { get; set; }
public decimal ZeroRatedSales { get; set; }
public decimal DiscountAmount { get; set; }
public decimal TotalDue { get; set; }
public string CustomerType { get; set; } = "Regular";
}
What does this model hold?
- GrossAmount stores the original VAT-inclusive price the customer sees on the shelf.
- VatableSales and VatAmount hold the extracted base and the 12% tax.
- VatExemptSales and ZeroRatedSales are required BIR fields even when their value is zero for regular sales.
- DiscountAmount and CustomerType prepare the model for SC/PWD discounts later.
Now create the calculator service itself.
- Create a new folder Services in your GroceryPOS project, then create Services/VatCalculatorService.cs with the following code:
namespace GroceryPOS.Services;
using GroceryPOS.Models;
public class VatCalculatorService
{
private const decimal VatRate = 0.12m;
private const decimal VatDivisor = 1.12m;
private const decimal ScPwdDiscountRate = 0.20m;
public VatBreakdown ComputeRegular(decimal vatInclusiveTotal)
{
decimal vatableSales = Math.Round(vatInclusiveTotal / VatDivisor, 2);
decimal vatAmount = Math.Round(vatInclusiveTotal - vatableSales, 2);
return new VatBreakdown
{
GrossAmount = vatInclusiveTotal,
VatableSales = vatableSales,
VatAmount = vatAmount,
VatExemptSales = 0m,
ZeroRatedSales = 0m,
DiscountAmount = 0m,
TotalDue = vatInclusiveTotal,
CustomerType = "Regular"
};
}
}
What does ComputeRegular do?
- VatDivisor is 1.12. Dividing the VAT-inclusive total by 1.12 recovers the pre-tax base (Vatable Sales).
- Subtracting the base from the total gives the exact VAT Amount.
- Math.Round(..., 2) ensures peso-centavo precision on every line.
- TotalDue equals the original vatInclusiveTotal for regular customers because no discount applies.
- Save both files, then verify the project compiles by running:
dotnet build
You should see Build succeeded in the output.
Build failed with missing namespace?
- Confirm VatBreakdown.cs uses namespace GroceryPOS.Models; and VatCalculatorService.cs uses namespace GroceryPOS.Services; to match the folder structure.
- Check that the using GroceryPOS.Models; line is present in VatCalculatorService.cs.
Help me debug a build error in my VatCalculatorService.
The calculator also needs to handle SC/PWD customer types for the Secret Mission later. Add these two methods now so the service is complete.
- In Services/VatCalculatorService.cs, add the following two methods inside the class, below the ComputeRegular method (before the closing } of the class):
public VatBreakdown ComputeScPwd(decimal vatInclusiveTotal, string customerType)
{
decimal vatExclusiveBase = Math.Round(vatInclusiveTotal / VatDivisor, 2);
decimal discountAmount = Math.Round(vatExclusiveBase * ScPwdDiscountRate, 2);
decimal totalDue = vatExclusiveBase - discountAmount;
return new VatBreakdown
{
GrossAmount = vatInclusiveTotal,
VatableSales = 0m,
VatAmount = 0m,
VatExemptSales = vatExclusiveBase,
ZeroRatedSales = 0m,
DiscountAmount = discountAmount,
TotalDue = totalDue,
CustomerType = customerType
};
}
public VatBreakdown Compute(decimal vatInclusiveTotal, string customerType)
{
return customerType switch
{
"SC" or "PWD" => ComputeScPwd(vatInclusiveTotal, customerType),
_ => ComputeRegular(vatInclusiveTotal)
};
}
What do these methods do?
- ComputeScPwd handles the SC/PWD discount formula: remove VAT first, then apply 20% discount to the VAT-exclusive base. VAT is zero for exempt sales.
- Compute is the public entry point that routes to the correct method based on customerType. For now the POS always passes "Regular".
- Save VatCalculatorService.cs and register the service in Program.cs. Find this line:
builder.Services.AddSingleton<ISaleRepository>(new SaleRepository(connectionString));
- Add the following line directly below it:
builder.Services.AddSingleton<VatCalculatorService>();
- Save Program.cs and run dotnet build again to verify everything still compiles.
✔️ Awesome, I've got everything!
Great. Confirm you have saved both VatCalculatorService.cs and Program.cs.
ⓧ I'd like to double check the full code
Your Services/VatCalculatorService.cs should contain three methods: ComputeRegular, ComputeScPwd, and Compute. Scroll up and confirm the first chunk (class + constants + ComputeRegular) is followed by the second chunk (ComputeScPwd + Compute) before the final closing brace. Your Program.cs should have builder.Services.AddSingleton<VatCalculatorService>(); right after the SaleRepository registration.
Integrate the VAT calculator into the sale processing
The service exists but the POS page does not use it yet. You need to inject the calculator into Pos.razor and replace the naive ProcessSale logic that stored zeros for VatableSales and VatAmount.
- In Components/Pages/Pos.razor, find these lines at the top of the file:
@using GroceryPOS.Models
@using GroceryPOS.Repositories
@inject IProductRepository ProductRepo
@inject ISaleRepository SaleRepo
- Replace them with the following, which adds the Services using directive and injects the calculator:
@using GroceryPOS.Models
@using GroceryPOS.Repositories
@using GroceryPOS.Services
@inject IProductRepository ProductRepo
@inject ISaleRepository SaleRepo
@inject VatCalculatorService VatCalc
What does injecting VatCalc give us?
Because you registered VatCalculatorService as a singleton in Program.cs, Blazor's dependency injection provides an instance automatically whenever the component loads. The @inject directive makes it available as VatCalc inside this component.
Next, replace the variables and methods in the @code block that handle sale processing.
- In the @code section, find these two variable declarations:
private bool showReceipt = false;
private decimal lastTotal = 0;
private string lastInvoiceNo = string.Empty;
- Replace them with:
private bool showReceipt = false;
private VatBreakdown? lastBreakdown;
private string lastInvoiceNo = string.Empty;
- Now find the entire ProcessSale method and replace it with:
private async Task ProcessSale()
{
if (!cart.Any()) return;
// Compute the VAT breakdown from the cart total
var breakdown = VatCalc.Compute(CartTotal, "Regular");
lastBreakdown = breakdown;
var sale = new Sale
{
SaleDate = DateTime.Now,
CustomerType = breakdown.CustomerType,
VatableSales = breakdown.VatableSales,
VatAmount = breakdown.VatAmount,
VatExemptSales = breakdown.VatExemptSales,
ZeroRatedSales = breakdown.ZeroRatedSales,
TotalDue = breakdown.TotalDue
};
var saleItems = cart.Select(c => new SaleItem
{
ProductId = c.ProductId,
ProductName = c.ProductName,
UnitPrice = c.Price,
Quantity = c.Quantity,
LineTotal = c.LineTotal
}).ToList();
await SaleRepo.InsertSaleAsync(sale, saleItems);
lastInvoiceNo = sale.InvoiceNo;
cart.Clear();
showReceipt = true;
}
What changed in ProcessSale?
- VatCalc.Compute(CartTotal, "Regular") calls the calculator, which divides the total by 1.12 and returns the breakdown.
- The Sale object now gets its VatableSales and VatAmount from the breakdown instead of hardcoded zeros.
- lastBreakdown stores the full breakdown so the receipt modal can display each field.
- Finally, find the CloseReceipt method and replace it with:
private void CloseReceipt()
{
showReceipt = false;
lastBreakdown = null;
}
Build the BIR-compliant receipt display
The final piece is replacing the receipt modal to show the BIR-mandated fields instead of a bare total.
- In Pos.razor, find the entire receipt modal section that starts with:
@if (showReceipt)
{
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
- Replace the entire @if (showReceipt) block (including the closing braces) with this updated receipt:
@if (showReceipt && lastBreakdown != null)
{
<div class="modal d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Sale Complete - @lastInvoiceNo</h5>
</div>
<div class="modal-body font-monospace">
<p><strong>Customer: @lastBreakdown.CustomerType</strong></p>
<hr />
<p>Vatable Sales: P @lastBreakdown.VatableSales.ToString("N2")</p>
<p>VAT Amount (12%): P @lastBreakdown.VatAmount.ToString("N2")</p>
<p>VAT-Exempt Sales: P @lastBreakdown.VatExemptSales.ToString("N2")</p>
<p>Zero-Rated Sales: P @lastBreakdown.ZeroRatedSales.ToString("N2")</p>
<hr />
<p class="fw-bold fs-5">TOTAL DUE: P @lastBreakdown.TotalDue.ToString("N2")</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" @onclick="CloseReceipt">New Transaction</button>
</div>
</div>
</div>
</div>
}
What does the BIR receipt show?
- Vatable Sales is the pre-tax base (total / 1.12).
- VAT Amount (12%) is the extracted tax.
- VAT-Exempt Sales and Zero-Rated Sales are zero for regular customers but required on every BIR invoice.
- Total Amount Due matches the original cart total for regular sales.
- Save Pos.razor.
Before you run the app, predict: if you add Rice (P55.00) and Cooking Oil (P95.00) to the cart and process the sale, what will Vatable Sales be?
- Run the app:
dotnet run
- Open the POS page in your browser.
- Click Add on Rice 1kg and Cooking Oil 1L.
- Click Process Sale.
The receipt modal now shows: Vatable Sales = P133.93, VAT Amount (12%) = P16.07, Total Due = P150.00. That is the correct 12% extraction from a P150.00 VAT-inclusive total.
Receipt still showing only the total?
- Make sure you replaced the @if (showReceipt) condition with @if (showReceipt && lastBreakdown != null). Without the null check the modal may not render at all.
- Verify you removed the old private decimal lastTotal = 0; and replaced it with private VatBreakdown? lastBreakdown;.
- Confirm @inject VatCalculatorService VatCalc appears at the top of Pos.razor. If it is missing, the VatCalc reference in ProcessSale will cause a compile error.
Help me fix my BIR receipt display in Pos.razor.
To double-check the database, open SSMS or sqlcmd and query the Sales table. The most recent row should now have non-zero VatableSales and VatAmount values instead of the zeros from the previous step.
Your POS now produces BIR-compliant receipts. Every sale persists the correct VAT breakdown to SQL Express, ready for formal invoicing in the next project. Up next in the Secret Mission: handling SC/PWD discounts with VAT exemption.
Secret mission
Implement SC/PWD 20% Discount with VAT Exemption
Senior Citizens and PWDs in the Philippines get a 20% discount AND VAT exemption on purchases. The catch? Most POS systems get the formula wrong. The correct BIR formula removes VAT first, then applies the 20% discount to the VAT-exclusive base. Can you implement this correctly?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with SQL Server Express, so there are no ongoing cloud costs.
Resources you used:
- The GroceryPOS project folder on your Desktop (Blazor Server app, models, repositories, services).
- The GroceryPOS database in SQL Server Express (Products, Sales, SaleItems, Categories tables).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with Project 2 (full BIR-compliant invoicing).
- Your GroceryPOS project folder stays on your Desktop, ready to run with dotnet run.
- Your GroceryPOS database stays in SQL Server Express with all your seeded products and saved sales.
- SQL Server Express runs as a background service. It uses minimal resources when idle.
Pause - I'll come back to this later
Stop the running Blazor app process to free up memory. Your files and database remain intact for later.
- If the Blazor app is still running in your terminal, press Ctrl+C to stop it.
- Your project folder and database are unchanged. When you are ready to resume, open a terminal in the GroceryPOS folder and run dotnet run again.
Delete - I don't want to use this again
Remove all project files and the database entirely. This cannot be undone.
- If the Blazor app is running, press Ctrl+C to stop it first.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/GroceryPOS
On Windows, run this in PowerShell instead:
Remove-Item -Recurse -Force ~\Desktop\GroceryPOS
- Drop the database from SQL Server Express. Open SSMS or a sqlcmd session and run:
USE master;
GO
DROP DATABASE GroceryPOS;
GO
- Confirm the database is gone by running SELECT name FROM sys.databases; in your SQL session. You should no longer see GroceryPOS in the list.
Nice Work!
Nice Work!
You just built a fully functional grocery POS checkout system that correctly handles Philippine VAT computation and persists every sale to SQL Express.
You've learned how to:
- Build a grocery POS checkout with a product grid and live cart powered by LINQ operations for real-time totals.
- Persist sales to SQL Express via Dapper with proper transaction handling to guarantee atomicity across related tables.
- Implement BIR-compliant VAT extraction from VAT-inclusive Philippine retail prices using the correct formula (Total / 1.12) and display the mandated breakdown on every receipt.
- Secret Mission: Implemented SC/PWD 20% discount with VAT exemption using the correct BIR formula per Revenue Regulations No. 5-2017.
Ready to quiz yourself?