Learn how to easily add expense tracking to your web app with this step-by-step guide. Manage finances smarter and faster!

Book a call with an Expert
Starting a new venture? Need to upgrade your web app? RapidDev builds application with your growth in mind.
Why Expense Tracking Matters in Modern Web Apps
Adding expense tracking to your web application isn't just about following the fintech trend—it's about creating tangible value. Well-implemented expense tracking helps users manage their finances, provides businesses with spending insights, and can transform a simple web app into an indispensable tool that users open daily.
You have two fundamental paths:
For most businesses, a hybrid approach works best: build your core data model while leveraging specialized services for complex features like receipt scanning or bank connections.
Here's a simplified but effective data model to get started:
CREATE TABLE expenses (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
description VARCHAR(255),
category_id INT,
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
receipt_url VARCHAR(255),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (category_id) REFERENCES categories(id)
);
CREATE TABLE categories (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
icon VARCHAR(50),
color VARCHAR(20),
user_id INT, // NULL for system categories, user_id for custom ones
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE tags (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
user_id INT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE expense_tags (
expense_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (expense_id, tag_id),
FOREIGN KEY (expense_id) REFERENCES expenses(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
1. RESTful API Endpoints
// Express.js example
const express = require('express');
const router = express.Router();
// Create expense
router.post('/expenses', authenticateUser, async (req, res) => {
try {
const { amount, description, category_id, date, receipt_url } = req.body;
const userId = req.user.id;
// Validate inputs
if (!amount || !date) {
return res.status(400).json({ error: 'Amount and date are required' });
}
// Create expense record
const expense = await db.expenses.create({
user_id: userId,
amount,
description,
category_id,
date,
receipt_url
});
// Handle tags if provided
if (req.body.tags && Array.isArray(req.body.tags)) {
await linkTagsToExpense(expense.id, req.body.tags);
}
res.status(201).json(expense);
} catch (error) {
res.status(500).json({ error: 'Failed to create expense' });
}
});
// Additional endpoints for GET, PUT, DELETE, etc.
2. Input Validation and Sanitization
// Using express-validator
const { body, validationResult } = require('express-validator');
const validateExpense = [
body('amount').isNumeric().withMessage('Amount must be a number'),
body('amount').custom(value => value > 0).withMessage('Amount must be positive'),
body('date').isISO8601().withMessage('Invalid date format'),
body('description').trim().escape(),
body('category_id').optional().isInt(),
body('tags').optional().isArray(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
];
// Then use in routes
router.post('/expenses', authenticateUser, validateExpense, expenseController.create);
3. Efficient Query Structure for Reports
// Get monthly summary
router.get('/expenses/summary/monthly', authenticateUser, async (req, res) => {
try {
const userId = req.user.id;
const { year, month } = req.query;
const summary = await db.query(`
SELECT
c.name as category,
c.color,
SUM(e.amount) as total,
COUNT(e.id) as count
FROM expenses e
JOIN categories c ON e.category_id = c.id
WHERE e.user_id = ?
AND YEAR(e.date) = ?
AND MONTH(e.date) = ?
GROUP BY c.id
ORDER BY total DESC
`, [userId, year, month]);
// Calculate overall total
const total = summary.reduce((acc, curr) => acc + parseFloat(curr.total), 0);
res.json({
summary,
total,
currency: req.user.preferred_currency // Assuming this exists
});
} catch (error) {
res.status(500).json({ error: 'Failed to generate summary' });
}
});
1. User-Friendly Expense Entry Form
// React component example
function ExpenseForm({ onSubmit, categories, isLoading }) {
const [formData, setFormData] = useState({
amount: '',
description: '',
category_id: '',
date: new Date().toISOString().split('T')[0],
tags: []
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
return (
<form onSubmit={handleSubmit} className="expense-form">
<div className="form-group">
<label htmlFor="amount">Amount*</label>
<div className="amount-input">
<span className="currency-symbol">$</span>
<input
type="number"
id="amount"
name="amount"
value={formData.amount}
onChange={handleChange}
step="0.01"
required
autoFocus
/>
</div>
</div>
<div className="form-group">
<label htmlFor="date">Date*</label>
<input
type="date"
id="date"
name="date"
value={formData.date}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="category">Category</label>
<select
id="category"
name="category_id"
value={formData.category_id}
onChange={handleChange}
>
<option value="">Select Category</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="2"
/>
</div>
<TagSelector
selectedTags={formData.tags}
onChange={tags => setFormData(prev => ({ ...prev, tags }))}
/>
<button
type="submit"
className="submit-button"
disabled={isLoading}
>
{isLoading ? 'Saving...' : 'Save Expense'}
</button>
</form>
);
}
2. Data Visualization for Expense Analysis
// Using Chart.js with React
import { Doughnut } from 'react-chartjs-2';
function ExpenseDashboard({ expenseData }) {
// Prepare data for chart
const chartData = {
labels: expenseData.summary.map(item => item.category),
datasets: [{
data: expenseData.summary.map(item => item.total),
backgroundColor: expenseData.summary.map(item => item.color || getRandomColor()),
borderWidth: 1
}]
};
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'right',
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw || 0;
const percentage = ((value / expenseData.total) * 100).toFixed(1);
return `${label}: $${value} (${percentage}%)`;
}
}
}
}
};
return (
<div className="dashboard-container">
<div className="summary-header">
<h3>Monthly Expenses</h3>
<div className="total-display">
<span className="total-label">Total:</span>
<span className="total-amount">${expenseData.total.toFixed(2)}</span>
</div>
</div>
<div className="chart-container">
<Doughnut data={chartData} options={chartOptions} />
</div>
<div className="category-breakdown">
{expenseData.summary.map(item => (
<div key={item.category} className="category-item">
<div className="category-color" style={{ backgroundColor: item.color }}></div>
<div className="category-name">{item.category}</div>
<div className="category-amount">${item.total.toFixed(2)}</div>
<div className="category-percentage">
{((item.total / expenseData.total) * 100).toFixed(1)}%
</div>
</div>
))}
</div>
</div>
);
}
3. Receipt Management with Image Upload
function ReceiptUploader({ onUploadComplete }) {
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleFileSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
// Validate file type and size
if (!['image/jpeg', 'image/png', 'image/heic', 'application/pdf'].includes(file.type)) {
alert('Please upload an image or PDF file');
return;
}
if (file.size > 10 * 1024 * 1024) { // 10MB limit
alert('File size exceeds 10MB limit');
return;
}
setIsUploading(true);
try {
// Get presigned URL for direct upload
const response = await fetch('/api/get-upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, fileType: file.type })
});
const { uploadUrl, fileUrl } = await response.json();
// Upload to S3 or your storage provider
await fetch(uploadUrl, {
method: 'PUT',
body: file,
onUploadProgress: progressEvent => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setProgress(percentCompleted);
}
});
// Optionally use OCR to extract expense details
const ocrResponse = await fetch('/api/extract-receipt-data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receiptUrl: fileUrl })
});
const extractedData = await ocrResponse.json();
onUploadComplete({
receiptUrl: fileUrl,
extractedData: extractedData.success ? extractedData.data : null
});
} catch (error) {
console.error('Upload failed:', error);
alert('Failed to upload receipt. Please try again.');
} finally {
setIsUploading(false);
setProgress(0);
}
};
return (
<div className="receipt-uploader">
<div className="upload-area">
<input
type="file"
id="receipt-upload"
accept="image/*,application/pdf"
onChange={handleFileSelect}
disabled={isUploading}
/>
<label htmlFor="receipt-upload" className="upload-button">
{isUploading ? 'Uploading...' : 'Add Receipt Photo'}
</label>
{isUploading && (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
></div>
<span className="progress-text">{progress}%</span>
</div>
)}
</div>
</div>
);
}
1. Recurring Expenses Automation
// Backend code for handling recurring expenses
function scheduleRecurringExpenses() {
cron.schedule('0 1 * * *', async () => { // Run daily at 1 AM
try {
// Find all recurring expenses due today
const today = new Date().toISOString().split('T')[0];
const recurringExpenses = await db.query(`
SELECT * FROM recurring_expenses
WHERE next_date = ? AND status = 'active'
`, [today]);
// Process each recurring expense
for (const recurExp of recurringExpenses) {
// Create a new expense instance
await db.expenses.create({
user_id: recurExp.user_id,
amount: recurExp.amount,
description: recurExp.description,
category_id: recurExp.category_id,
date: today,
is_recurring: true,
recurring_id: recurExp.id
});
// Update next occurrence date based on frequency
const nextDate = calculateNextDate(today, recurExp.frequency);
await db.recurring_expenses.update(
{ next_date: nextDate },
{ where: { id: recurExp.id }}
);
}
console.log(`Processed ${recurringExpenses.length} recurring expenses`);
} catch (error) {
console.error('Error processing recurring expenses:', error);
}
});
}
// Helper function to calculate next date
function calculateNextDate(currentDate, frequency) {
const date = new Date(currentDate);
switch (frequency) {
case 'daily':
date.setDate(date.getDate() + 1);
break;
case 'weekly':
date.setDate(date.getDate() + 7);
break;
case 'monthly':
date.setMonth(date.getMonth() + 1);
break;
case 'quarterly':
date.setMonth(date.getMonth() + 3);
break;
case 'yearly':
date.setFullYear(date.getFullYear() + 1);
break;
default:
throw new Error(`Unknown frequency: ${frequency}`);
}
return date.toISOString().split('T')[0];
}
2. Budget Tracking and Alerts
// React component for budget setup and monitoring
function BudgetManager({ categories, currentMonthExpenses }) {
const [budgets, setBudgets] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Fetch user's budget settings
const fetchBudgets = async () => {
try {
const response = await fetch('/api/budgets');
const data = await response.json();
setBudgets(data);
} catch (error) {
console.error('Failed to fetch budgets:', error);
} finally {
setIsLoading(false);
}
};
fetchBudgets();
}, []);
// Calculate budget progress for each category
const budgetProgress = useMemo(() => {
return budgets.map(budget => {
// Find matching expenses for this budget
const matchingExpenses = currentMonthExpenses.filter(exp => {
if (budget.category_id) {
return exp.category_id === budget.category_id;
} else {
// This is a "total" budget that applies to all expenses
return true;
}
});
// Sum up the expenses
const spent = matchingExpenses.reduce((sum, exp) => sum + exp.amount, 0);
const percentage = (spent / budget.amount) * 100;
return {
...budget,
spent,
remaining: budget.amount - spent,
percentage,
status: percentage >= 100 ? 'exceeded' :
percentage >= 80 ? 'warning' : 'ok'
};
});
}, [budgets, currentMonthExpenses]);
// Render budget cards
return (
<div className="budget-manager">
<h3>Monthly Budgets</h3>
{isLoading ? (
<div className="loading">Loading budgets...</div>
) : (
<>
<div className="budget-cards">
{budgetProgress.map(budget => (
<div
key={budget.id}
className={`budget-card status-${budget.status}`}
>
<div className="budget-header">
<h4>{budget.category_id ?
categories.find(c => c.id === budget.category_id)?.name :
'Total Budget'}
</h4>
<span className="budget-amount">${budget.amount.toFixed(2)}</span>
</div>
<div className="budget-progress">
<div
className="progress-bar"
style={{ width: `${Math.min(budget.percentage, 100)}%` }}
></div>
</div>
<div className="budget-details">
<div className="budget-spent">
<span className="label">Spent:</span>
<span className="value">${budget.spent.toFixed(2)}</span>
</div>
<div className="budget-remaining">
<span className="label">Remaining:</span>
<span className="value">${budget.remaining.toFixed(2)}</span>
</div>
</div>
{budget.status === 'exceeded' && (
<div className="budget-alert">
Budget exceeded by ${(budget.spent - budget.amount).toFixed(2)}
</div>
)}
{budget.status === 'warning' && (
<div className="budget-warning">
Approaching budget limit ({budget.percentage.toFixed(0)}%)
</div>
)}
</div>
))}
</div>
<button className="add-budget-btn">Add New Budget</button>
</>
)}
</div>
);
}
3. Bank Integration for Automated Expense Import
// Using Plaid API for bank connection
const plaid = require('plaid');
const plaidClient = new plaid.Client({
clientID: process.env.PLAID_CLIENT_ID,
secret: process.env.PLAID_SECRET,
env: plaid.environments.development // Use sandbox, development, or production
});
// Link bank account
router.post('/link-account', authenticateUser, async (req, res) => {
try {
const { public_token, metadata } = req.body;
const userId = req.user.id;
// Exchange public token for access token
const tokenResponse = await plaidClient.exchangePublicToken(public_token);
const accessToken = tokenResponse.access_token;
// Store access token securely (encrypted)
await db.bankConnections.create({
user_id: userId,
institution_id: metadata.institution.institution_id,
institution_name: metadata.institution.name,
access_token: encryptToken(accessToken), // Implement secure encryption
last_sync: new Date()
});
// Initial import of transactions
await importTransactions(userId, accessToken);
res.json({ success: true });
} catch (error) {
console.error('Error linking account:', error);
res.status(500).json({ error: 'Failed to link account' });
}
});
// Function to import transactions
async function importTransactions(userId, accessToken) {
try {
// Get transactions for the last 30 days
const now = new Date();
const thirtyDaysAgo = new Date(now);
thirtyDaysAgo.setDate(now.getDate() - 30);
const startDate = thirtyDaysAgo.toISOString().split('T')[0];
const endDate = now.toISOString().split('T')[0];
const transactionsResponse = await plaidClient.getTransactions(
accessToken,
startDate,
endDate,
{
count: 500,
offset: 0
}
);
const transactions = transactionsResponse.transactions;
// Process each transaction
for (const transaction of transactions) {
// Skip deposits (positive amounts)
if (transaction.amount <= 0) continue;
// Check if transaction already exists
const existingTransaction = await db.expenses.findOne({
where: {
user_id: userId,
external_id: transaction.transaction_id
}
});
if (existingTransaction) continue;
// Map transaction category to your app's categories
const categoryId = await mapPlaidCategoryToInternal(
userId,
transaction.category,
transaction.category_id
);
// Create expense record
await db.expenses.create({
user_id: userId,
amount: transaction.amount,
description: transaction.name,
category_id: categoryId,
date: transaction.date,
external_id: transaction.transaction_id,
import_source: 'plaid',
merchant_name: transaction.merchant_name || null,
location: transaction.location ? JSON.stringify(transaction.location) : null
});
}
return transactions.length;
} catch (error) {
console.error('Error importing transactions:', error);
throw error;
}
}
1. Data Indexing Strategy
-- Add these indexes to your database for optimized queries
ALTER TABLE expenses ADD INDEX idx_user_date (user_id, date);
ALTER TABLE expenses ADD INDEX idx_category (category_id);
ALTER TABLE expenses ADD INDEX idx_user_category_date (user_id, category_id, date);
ALTER TABLE expenses ADD INDEX idx_external_id (external_id);
-- For faster search queries
ALTER TABLE expenses ADD FULLTEXT INDEX idx_expense_search (description);
2. Caching Expensive Calculations
// Using Redis for caching reports
const redis = require('redis');
const redisClient = redis.createClient({
url: process.env.REDIS_URL
});
// Middleware to cache expensive reports
function cacheReport(ttlSeconds = 3600) {
return async (req, res, next) => {
if (req.method !== 'GET') return next();
const userId = req.user.id;
const cacheKey = `report:${req.originalUrl}:user:${userId}`;
try {
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
// Return cached data
return res.json(JSON.parse(cachedData));
}
// Store original res.json to intercept the response
const originalJson = res.json;
res.json = function(data) {
// Cache the response before sending
redisClient.set(cacheKey, JSON.stringify(data), {
EX: ttlSeconds
});
// Restore and call the original json method
res.json = originalJson;
return res.json(data);
};
next();
} catch (error) {
console.error('Cache error:', error);
next(); // Continue without caching
}
};
}
// Apply to report endpoints
router.get('/expenses/summary/monthly',
authenticateUser,
cacheReport(1800), // 30 minutes cache
reportController.getMonthlyReport
);
// Invalidate cache when data changes
function invalidateUserCache(userId) {
redisClient.keys(`report:*:user:${userId}`, (err, keys) => {
if (err) return console.error('Cache invalidation error:', err);
if (keys.length > 0) {
redisClient.del(keys);
}
});
}
// Call after expense operations
router.post('/expenses', authenticateUser, async (req, res) => {
try {
// ... create expense logic
// Invalidate cache after creating an expense
invalidateUserCache(req.user.id);
res.status(201).json(expense);
} catch (error) {
res.status(500).json({ error: 'Failed to create expense' });
}
});
3. Optimizing for Mobile Users
// React component with progressive loading for mobile
function ExpensesList({ userId }) {
const [expenses, setExpenses] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const observer = useRef();
// Function to fetch a page of expenses
const fetchExpenses = useCallback(async (pageNum) => {
if (isLoading) return;
setIsLoading(true);
try {
const response = await fetch(
`/api/expenses?page=${pageNum}&limit=20&sort=date:desc`
);
const data = await response.json();
if (data.expenses.length > 0) {
setExpenses(prev =>
pageNum === 1 ? data.expenses : [...prev, ...data.expenses]
);
setHasMore(data.expenses.length === 20);
} else {
setHasMore(false);
}
} catch (error) {
console.error('Failed to fetch expenses:', error);
} finally {
setIsLoading(false);
}
}, [isLoading]);
// Initial load
useEffect(() => {
fetchExpenses(1);
}, [fetchExpenses]);
// Setup intersection observer for infinite scroll
const lastExpenseRef = useCallback(node => {
if (isLoading) return;
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
setPage(prevPage => prevPage + 1);
fetchExpenses(page + 1);
}
});
if (node) {
observer.current.observe(node);
}
}, [isLoading, hasMore, fetchExpenses, page]);
return (
<div className="expenses-list">
{expenses.map((expense, index) => (
<div
key={expense.id}
ref={index === expenses.length - 1 ? lastExpenseRef : null}
className="expense-item"
>
<div className="expense-date">
{new Date(expense.date).toLocaleDateString()}
</div>
<div className="expense-details">
<div className="expense-description">{expense.description}</div>
<div className="expense-category">{expense.category_name}</div>
</div>
<div className="expense-amount">${expense.amount.toFixed(2)}</div>
</div>
))}
{isLoading && (
<div className="loading-indicator">
<div className="spinner"></div>
<div>Loading more expenses...</div>
</div>
)}
{!hasMore && expenses.length > 0 && (
<div className="end-message">No more expenses to load</div>
)}
{!isLoading && expenses.length === 0 && (
<div className="empty-state">
<div className="empty-icon">💰</div>
<h3>No expenses yet</h3>
<p>Add your first expense to get started tracking your spending.</p>
<button className="add-expense-btn">Add Expense</button>
</div>
)}
</div>
);
}
1. Protecting Financial Data
// Database encryption for sensitive financial data
const crypto = require('crypto');
// Encryption key management (use a proper KMS in production)
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; // 32 byte key
const IV_LENGTH = 16; // For AES, this is always 16
// Encrypt sensitive data before storing
function encrypt(text) {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(
'aes-256-cbc',
Buffer.from(ENCRYPTION_KEY, 'hex'),
iv
);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') + ':' + encrypted.toString('hex');
}
// Decrypt data when needed
function decrypt(text) {
const parts = text.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encryptedText = Buffer.from(parts[1], 'hex');
const decipher = crypto.createDecipheriv(
'aes-256-cbc',
Buffer.from(ENCRYPTION_KEY, 'hex'),
iv
);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
// Example: Storing bank access tokens securely
async function storeBankToken(userId, institutionId, accessToken) {
const encryptedToken = encrypt(accessToken);
await db.bankConnections.create({
user_id: userId,
institution_id: institutionId,
access_token: encryptedToken
});
}
// Example: Retrieving and using the token
async function getBankTransactions(userId, connectionId) {
const connection = await db.bankConnections.findOne({
where: { id: connectionId, user_id: userId }
});
if (!connection) throw new Error('Connection not found');
const accessToken = decrypt(connection.access_token);
// Now use the access token with your banking API
return plaidClient.getTransactions(accessToken, startDate, endDate);
}
Start Small, Scale Intelligently
Begin with a simple expense tracker that handles manual entry well. Focus on getting the core user experience right first—a delightful form for adding expenses and clear, useful visualizations for reviewing spending patterns.
Phase Your Implementation
Measure the Right Metrics
Track how often users log expenses, whether they return to analyze their spending, and the completeness of their records. These insights will guide your future development better than generic engagement metrics.
Data Quality Trumps Quantity
In expense tracking, incomplete or inaccurate data is worse than no data at all. Design your system to make accuracy easy through smart defaults, validation, and gentle nudges that encourage good record-keeping.
Remember that expense tracking is both a technical and behavioral challenge. The most elegant code won't matter if users don't develop the habit of recording their expenses. Your implementation should focus as much on behavioral design as on technical correctness.
Explore the top 3 practical use cases for adding expense tracking to your web app.
An organizational nervous system that transforms scattered transactions into actionable financial insights. Beyond simple record-keeping, this approach lets businesses identify spending patterns, optimize budgets, and make data-driven decisions about resource allocation.
A systematic shield that protects organizations from regulatory penalties and audit headaches. This approach transforms expense tracking from a reactive chore into a proactive compliance strategy that reduces financial risk and builds stakeholder trust.
A collaborative framework that distributes financial responsibility while maintaining central oversight. This approach turns every employee into a financial steward while giving management the tools to guide spending behavior toward strategic goals.
From startups to enterprises and everything in between, see for yourself our incredible impact.
Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We’ll discuss your project and provide a custom quote at no cost.Â