| version | 1.0 | |||||
|---|---|---|---|---|---|---|
| date | 2026-05-14 | |||||
| author | Manoj Pandi | |||||
| status | Production Ready | |||||
| tags |
|
|||||
| related_documents |
|
The Business API manages two core entities for invoicing workflows: Customers (people/businesses you bill) and Products (catalog of items you sell). Both support CRUD operations with soft deletion, search filtering, and integration with Razorpay (customers) and invoice creation (products).
Tenant
├─ Customer 1
│ ├─ Invoice 1 (BINV-456-202605-0001)
│ │ ├─ Line Item → Product A
│ │ └─ Line Item → Product B
│ │
│ ├─ Invoice 2 (BINV-456-202605-0002)
│ │ └─ Line Item → Product A
│ │
│ └─ Payment 1 (UTR 123456789)
│
├─ Customer 2
│ └─ Invoice 3
│
├─ Product A (name: "Professional Plan", price: ₹5,000)
├─ Product B (name: "Training", price: ₹2,000)
└─ Product C (name: "License", price: ₹10,000)
| Entity | Purpose | Example |
|---|---|---|
| Customer | Who to bill | Acme Corp, John Doe |
| Product | What to bill for | "Professional Plan", "Training Hours", "License" |
Billing workflow:
- Create customer
- Create product(s)
- Create invoice → select customer + add line items (products)
- Record payment(s)
- Frontend (React) — Customer/product management dashboard
- Invoice API — References customers and products
CustomerService— Persistence, Razorpay syncProductService— Catalog managementRazorpay API— Sync customer for payment linksDatabase— PostgreSQL (tenant schema)
business:
customer:
allow-duplicate-emails: false
sync-to-razorpay: true
product:
allow-duplicate-names: false
default-tax-percentage: 0
allow-deactivation: truePurpose: Create a new customer
Request:
{
"name": "Acme Corp",
"email": "billing@acme.com",
"phone": "+91-9876543210",
"address": "123 Business Street, Mumbai, India",
"gstin": "27AAPCS1234H1Z0"
}Response (201 Created):
{
"success": true,
"data": {
"id": 42,
"name": "Acme Corp",
"email": "billing@acme.com",
"phone": "+91-9876543210",
"address": "123 Business Street, Mumbai, India",
"gstin": "27AAPCS1234H1Z0",
"razorpayCustomerId": "cust_2c7P8k4x9M3z5q",
"createdAt": "2026-05-14T14:30:00Z",
"updatedAt": "2026-05-14T14:30:00Z"
},
"message": "Customer created successfully"
}Validations:
name— Required, max 255 charsemail— Valid email format, unique per tenantphone— Optional, max 50 charsaddress— Optional, can be long textgstin— Optional, 15-char alphanumeric (GST registration for B2B)
Side effects:
- Stores customer in tenant schema
- Best-effort sync to Razorpay (for payment links later)
- Records in
AuditLog
Error codes:
400— Email already exists in this tenant400— Invalid GSTIN format400— Invalid email format
Purpose: List all customers
Request:
GET /api/v1/customers?search=Acme&page=0&size=20&sort=createdAt,desc
Response (200 OK):
{
"success": true,
"data": {
"content": [
{
"id": 42,
"name": "Acme Corp",
"email": "billing@acme.com",
"phone": "+91-9876543210",
"address": "123 Business Street, Mumbai, India",
"gstin": "27AAPCS1234H1Z0",
"razorpayCustomerId": "cust_2c7P8k4x9M3z5q",
"createdAt": "2026-05-14T14:30:00Z"
}
],
"totalElements": 1,
"totalPages": 1,
"currentPage": 0,
"pageSize": 20
},
"message": "Customers fetched successfully"
}Parameters:
search— Optional, matches name or email (case-insensitive, substring)page,size— Pagination- Default sort:
createdAtDESC
Search example:
- Query: "acme" → matches "Acme Corp", "ACME Industries", etc.
- Query: "billing" → matches "billing@acme.com", "billing@company.com"
Purpose: Get single customer
Response (200 OK):
{
"success": true,
"data": {
"id": 42,
"name": "Acme Corp",
"email": "billing@acme.com",
"phone": "+91-9876543210",
"address": "123 Business Street, Mumbai, India",
"gstin": "27AAPCS1234H1Z0",
"razorpayCustomerId": "cust_2c7P8k4x9M3z5q",
"createdAt": "2026-05-14T14:30:00Z",
"updatedAt": "2026-05-14T14:30:00Z"
},
"message": "Customer fetched successfully"
}Purpose: Update customer
Request: (all fields optional, send only what changed)
{
"name": "Acme Corporation",
"phone": "+91-9876543211",
"gstin": "27AAPCS1234H1Z1"
}Response (200 OK):
{
"success": true,
"data": {
"id": 42,
"name": "Acme Corporation",
"email": "billing@acme.com",
"phone": "+91-9876543211",
"address": "123 Business Street, Mumbai, India",
"gstin": "27AAPCS1234H1Z1",
"razorpayCustomerId": "cust_2c7P8k4x9M3z5q",
"updatedAt": "2026-05-14T15:45:00Z"
},
"message": "Customer updated successfully"
}Error codes:
400— Email already taken (by another customer)400— Invalid GSTIN format404— Customer not found
Purpose: Soft-delete a customer
Response (200 OK):
{
"success": true,
"data": null,
"message": "Customer deleted successfully"
}Preconditions:
- No open or paid invoices (must void first)
Error codes:
400— Customer has active invoices (OPEN or PAID)404— Customer not found
Side effects:
- Marks customer as deleted (soft delete)
- Historical invoices remain visible for audit
- Cannot be undone
Purpose: Add a product to catalog
Request:
{
"name": "Professional Plan",
"description": "Annual professional tier with unlimited users",
"price": 5000.00,
"taxPercentage": 18.00,
"hsnSacCode": "998361",
"unit": "subscription"
}Response (201 Created):
{
"success": true,
"data": {
"id": 1,
"name": "Professional Plan",
"description": "Annual professional tier with unlimited users",
"price": 5000.00,
"taxPercentage": 18.00,
"hsnSacCode": "998361",
"unit": "subscription",
"isActive": true,
"createdAt": "2026-05-14T14:30:00Z",
"updatedAt": "2026-05-14T14:30:00Z"
},
"message": "Product created successfully"
}Validations:
name— Required, unique per tenant, max 255 charsdescription— Optionalprice— Required, ≥ 0taxPercentage— Optional, default 0 (tax-exempt)hsnSacCode— Optional, GST classification (max 20 chars)unit— Optional, e.g. "hours", "kg", "license" (max 50 chars)
Tax calculation:
If taxPercentage = 18 and price = 5000:
Tax amount per unit = 5000 × 18 / 100 = 900
Total per unit = 5000 + 900 = 5900
Error codes:
400— Product name already exists400— Invalid price (negative)
Purpose: List all products (active and inactive)
Request:
GET /api/v1/products?search=Professional&page=0&size=20
Response (200 OK):
{
"success": true,
"data": {
"content": [
{
"id": 1,
"name": "Professional Plan",
"description": "Annual professional tier",
"price": 5000.00,
"taxPercentage": 18.00,
"hsnSacCode": "998361",
"unit": "subscription",
"isActive": true,
"createdAt": "2026-05-14T14:30:00Z"
},
{
"id": 2,
"name": "Professional Plan (Legacy)",
"price": 4000.00,
"isActive": false,
"createdAt": "2026-04-01T00:00:00Z"
}
],
"totalElements": 2,
"totalPages": 1,
"currentPage": 0,
"pageSize": 20
},
"message": "Products fetched successfully"
}Parameters:
search— Optional, matches product name- Ordering: Active first, then alphabetically by name
Purpose: Get active products only (for invoice creation)
Request:
GET /api/v1/products/active
Response (200 OK):
{
"success": true,
"data": [
{
"id": 1,
"name": "Professional Plan",
"price": 5000.00,
"taxPercentage": 18.00,
"unit": "subscription",
"isActive": true
},
{
"id": 3,
"name": "Training Hours",
"price": 2000.00,
"taxPercentage": 18.00,
"unit": "hours",
"isActive": true
}
],
"message": "Active products fetched successfully"
}Use case: Populate dropdown when creating invoice line items
Features:
- Not paginated (returns all active products)
- Alphabetically sorted
Purpose: Get single product (active or inactive)
Response (200 OK):
{
"success": true,
"data": {
"id": 1,
"name": "Professional Plan",
"description": "Annual professional tier",
"price": 5000.00,
"taxPercentage": 18.00,
"hsnSacCode": "998361",
"unit": "subscription",
"isActive": true,
"createdAt": "2026-05-14T14:30:00Z",
"updatedAt": "2026-05-14T14:30:00Z"
},
"message": "Product fetched successfully"
}Purpose: Update product
Request: (all optional)
{
"price": 6000.00,
"description": "Updated description",
"taxPercentage": 18.00
}Response (200 OK):
{
"success": true,
"data": {
"id": 1,
"name": "Professional Plan",
"price": 6000.00,
"taxPercentage": 18.00,
"isActive": true,
"updatedAt": "2026-05-14T15:45:00Z"
},
"message": "Product updated successfully"
}Critical:
- Price/tax changes DO NOT affect existing invoices
- Values are snapshotted at invoice creation time
- Only applies to future invoices
Error codes:
400— Product name already taken404— Product not found
Purpose: Deactivate a product
Response (200 OK):
{
"success": true,
"data": null,
"message": "Product deactivated successfully"
}Effect:
- Sets
isActive = false - Cannot be added to new invoices
- Existing invoice line items remain visible (immutable)
- Can be reactivated by updating
Rationale: Maintain audit trail of historical pricing
// Create customer
const useCreateCustomer = () => {
return useMutation({
mutationFn: (customer) =>
apiClient.post('/api/v1/customers', customer),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
showSuccess('Customer created');
}
});
};
// Search customers for dropdown
const useSearchCustomers = (search) => {
return useQuery({
queryKey: ['customers', search],
queryFn: () => apiClient.get('/api/v1/customers', {
params: { search, size: 10 }
}),
enabled: search.length > 0
});
};
// Create product
const useCreateProduct = () => {
return useMutation({
mutationFn: (product) =>
apiClient.post('/api/v1/products', product),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
showSuccess('Product added');
}
});
};
// Get active products for invoice line item dropdown
const useActiveProducts = () => {
return useQuery({
queryKey: ['activeProducts'],
queryFn: () => apiClient.get('/api/v1/products/active')
});
};function CustomerForm() {
const [form, setForm] = useState({
name: '',
email: '',
phone: '',
address: '',
gstin: ''
});
const { mutate: createCustomer, isPending } = useCreateCustomer();
const handleSubmit = (e) => {
e.preventDefault();
createCustomer(form);
};
return (
<form onSubmit={handleSubmit}>
<input
placeholder="Customer name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
<input
placeholder="Phone"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
<textarea
placeholder="Address"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
/>
<input
placeholder="GSTIN (15-char)"
value={form.gstin}
onChange={(e) => setForm({ ...form, gstin: e.target.value })}
pattern="[0-9A-Z]{15}"
/>
<button type="submit" disabled={isPending}>
Create Customer
</button>
</form>
);
}function InvoiceCreator() {
const { data: customers } = useQuery({
queryKey: ['customers'],
queryFn: () => apiClient.get('/api/v1/customers')
});
const { data: activeProducts } = useActiveProducts();
const [form, setForm] = useState({
customerId: '',
items: [{ productId: null, quantity: 1 }]
});
const handleAddItem = () => {
setForm({
...form,
items: [...form.items, { productId: null, quantity: 1 }]
});
};
const handleSubmit = () => {
apiClient.post('/api/v1/business-invoices', {
customerId: form.customerId,
items: form.items.map(item => ({
productId: item.productId,
quantity: item.quantity
}))
});
};
return (
<div>
<select
value={form.customerId}
onChange={(e) => setForm({ ...form, customerId: e.target.value })}
>
<option value="">Select customer</option>
{customers?.data?.content?.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
{form.items.map((item, idx) => (
<div key={idx}>
<select
value={item.productId || ''}
onChange={(e) => {
const newItems = [...form.items];
newItems[idx].productId = parseInt(e.target.value);
setForm({ ...form, items: newItems });
}}
>
<option value="">Select product</option>
{activeProducts?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<input
type="number"
value={item.quantity}
onChange={(e) => {
const newItems = [...form.items];
newItems[idx].quantity = parseInt(e.target.value);
setForm({ ...form, items: newItems });
}}
/>
</div>
))}
<button onClick={handleAddItem}>Add Item</button>
<button onClick={handleSubmit}>Create Invoice</button>
</div>
);
}@Service
@RequiredArgsConstructor
@Transactional
public class CustomerService {
private final CustomerRepository repository;
private final PaymentGatewayPort paymentGateway;
public CustomerResponse create(CreateCustomerRequest request) {
// Validate email uniqueness
if (repository.existsByEmail(request.getEmail())) {
throw BillingException.duplicateEmail();
}
// Create customer
Customer customer = Customer.builder()
.name(request.getName())
.email(request.getEmail())
.phone(request.getPhone())
.address(request.getAddress())
.gstin(request.getGstin())
.build();
repository.save(customer);
// Sync to Razorpay (best-effort)
try {
String razorpayId = paymentGateway.syncCustomer(customer);
customer.setRazorpayCustomerId(razorpayId);
repository.save(customer);
} catch (Exception e) {
log.warn("Failed to sync customer to Razorpay", e);
// Continue anyway — Razorpay sync is optional
}
return toResponse(customer);
}
}
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {
private final ProductRepository repository;
public ProductResponse create(CreateProductRequest request) {
// Validate name uniqueness
if (repository.existsByName(request.getName())) {
throw BillingException.duplicateProductName();
}
// Create product
Product product = Product.builder()
.name(request.getName())
.description(request.getDescription())
.price(request.getPrice())
.taxPercentage(request.getTaxPercentage() != null
? request.getTaxPercentage()
: BigDecimal.ZERO)
.hsnSacCode(request.getHsnSacCode())
.unit(request.getUnit())
.isActive(true)
.build();
repository.save(product);
return toResponse(product);
}
}See error-handling.md for complete error list.
Common errors:
| Error Code | HTTP | Message | Cause |
|---|---|---|---|
BIZ_CUST_001 |
400 | Email already exists | Duplicate email in tenant |
BIZ_CUST_002 |
400 | Cannot delete customer with invoices | Has OPEN or PAID invoices |
BIZ_PROD_001 |
400 | Product name already exists | Duplicate name in tenant |
BIZ_PROD_002 |
400 | Invalid tax percentage | Negative value |
- Keep GSTIN updated — Required for B2B GST invoices
- Email is unique — Cannot have duplicate emails per tenant
- Search before creating — Check for existing customers with similar names
- Soft delete only — Deleted customers still appear in historical invoices
- Name uniqueness — Name must be unique per tenant
- Plan for tax — Set correct taxPercentage at creation (common mistake: forgetting GST)
- HSN/SAC codes — Required for GST compliance in India (18-digit classification)
- Units matter — Use consistent units ("hrs", "kg", "licenses") for clarity
- Price changes safe — Updating price does NOT affect historical invoices
1. Create Customers
POST /api/v1/customers
2. Create Products
POST /api/v1/products
3. Create Invoices (references customers + products)
POST /api/v1/business-invoices
4. Invoice → Line Items → Products (snapshotted)
5. Update Product price
PUT /api/v1/products/{id}
(Only affects new invoices, not existing ones)
| Feature | Mechanism | Example |
|---|---|---|
| Customer creation | CRUD with Razorpay sync | Create → Razorpay ID stored |
| Product catalog | CRUD with active/inactive | Deactivate old products |
| Email uniqueness | Per-tenant validation | Cannot create duplicate emails |
| Tax calculation | Per-line-item snapshots | 18% GST on product |
| Price immutability | Snapshot at invoice creation | Product price change doesn't affect past invoices |
| Search | Substring matching | "Acme" matches "Acme Corp" |
| Soft delete | Archive, don't destroy | Deleted customers still in audit trail |