Package System
Complete implementation guide for the Package system in Salonnz, covering service bundles, group-based pricing, and usage tracking.
Overview
The Package system allows salons to sell bundled services at discounted rates. Packages organize services into groups with defined quantities, enabling flexible service combinations.
Key Features
- Service Groups: Organize multiple services into logical groups
- Quantity-Based: Each group has a specific quantity of uses
- Session Types: Single-use or multi-use configurations
- Flexible Pricing: Group-level pricing for better control
- Usage Tracking: Real-time tracking of service redemptions
- Expiration Management: Configure package expiration periods
- Tax Configuration: Optional tax application
Admin Panel Configuration
Package Management
Location: Admin Panel → Settings → Packages → Manage Packages
File: Frontend-Admin-Panel/pages/setting/packages/index.vue
Creating a Package
Package-Level Fields
| Field | Type | Description |
|---|---|---|
name | VARCHAR | Package name (e.g., "Spa Deluxe Package") |
price | DECIMAL | Total package price |
service_expiration | INTEGER | Expiration in months (0 = never) |
session | TOGGLE | 0 = Single-use, 1 = Multi-use |
tax | TOGGLE | Apply service tax (0/1) |
price_for_payroll | SELECT | 1 = Regular, 2 = Discount |
status | TOGGLE | Active (1) / Inactive (0) |
Service Group Fields
Each package contains one or more service groups:
| Field | Type | Description |
|---|---|---|
qty | INTEGER | Total quantity available in this group |
price | DECIMAL | Price for this group |
services[] | MULTI-SELECT | Services included in this group |
Configuration Examples
Example 1: Simple Package
Scenario: Haircut package - 5 haircuts for $150
{
"name": "Haircut Package - 5 Sessions",
"price": 150,
"service_expiration": 6, // 6 months validity
"session": 1, // Multi-use
"tax": 1,
"status": 1,
"service_group": [
{
"qty": 5,
"price": 150,
"services": [5] // Haircut service
}
]
}
Result: Customer can get 5 haircuts (normally $35 each = $175) for $150
Example 2: Multi-Group Package
Scenario: Spa package with different service categories
{
"name": "Ultimate Spa Package",
"price": 500,
"service_expiration": 12,
"session": 1,
"tax": 1,
"service_group": [
{
"qty": 5,
"price": 300,
"services": [10, 12, 15] // Massage services
},
{
"qty": 3,
"price": 200,
"services": [20, 22] // Facial services
}
]
}
Result:
- 5 massage sessions (choose from 3 options)
- 3 facial sessions (choose from 2 options)
- Total: 8 services for $500
Example 3: Single-Use Package
Scenario: Wedding day package - use all services in one session
{
"name": "Bridal Package",
"price": 400,
"service_expiration": 0,
"session": 0, // Single-use - all services in one booking
"tax": 1,
"service_group": [
{
"qty": 1,
"price": 400,
"services": [5, 10, 15, 20] // Hair, makeup, nails, facial
}
]
}
Result: All services must be used in a single appointment
Session Types Explained
Multi-Use (session = 1)
Behavior: Customer can redeem services separately over multiple appointments
Example:
- Package: 5 haircuts
- Booking 1: Use 2 haircuts
- Remaining: 3 haircuts
- Booking 2: Use 1 haircut
- Remaining: 2 haircuts
Use Case: Regular maintenance packages (haircuts, massages)
Single-Use (session = 0)
Behavior: All services must be used in one appointment
Example:
- Package: Hair + Makeup + Nails
- Customer must book all 3 services together
- Cannot split across appointments
Use Case: Event packages (weddings, proms, photoshoots)
Admin API Endpoints
1. Get All Packages
GET /admin/package/index
Controller: PackageController.php::index()
Response:
{
"status": true,
"data": [
{
"id": 1,
"name": "Haircut Package - 5 Sessions",
"price": "150",
"service_expiration": 6,
"session": 1,
"tax": 1,
"status": 1,
"service_group": [
{
"id": 10,
"package_id": 1,
"qty": 5,
"price": "150",
"services": [
{
"id": 5,
"group_id": 10,
"service_id": "5",
"service": {
"id": 5,
"name": "Men's Haircut",
"price": "35",
"duration": 30
}
}
]
}
]
}
]
}
Cache Key: package_get_list
2. Create Package
POST /admin/package/store
Request Body:
{
"name": "Ultimate Spa Package",
"price": 500,
"image": "base64_encoded_image",
"service_expiration": 12,
"session": 1,
"tax": 1,
"price_for_payroll": 2,
"status": 1,
"service_group": [
{
"qty": 5,
"price": 300,
"service": [10, 12, 15]
},
{
"qty": 3,
"price": 200,
"service": [20, 22]
}
]
}
Validation Rules:
name: Required, stringprice: Required, numericservice_expiration: Required, integerservice_group: Required array, minimum 1 groupservice_group.*.qty: Required, integer, minimum 1service_group.*.service: Required array, minimum 1 service
Backend Process (PackageController.php::store()):
// 1. Create package
$package = Packages::create([
'name' => $request->name,
'price' => $request->price,
'image' => $uploaded_image_url,
'service_expiration' => $request->service_expiration,
'session' => $request->session,
'tax' => $request->tax,
'price_for_payroll' => $request->price_for_payroll,
'status' => $request->status
]);
// 2. Create service groups
foreach ($request->service_group as $sg) {
$packages_group = PackageServiceGroup::create([
'package_id' => $package->id,
'qty' => $sg['qty'],
'price' => $sg['price']
]);
// 3. Link services to group
foreach ($sg['service'] as $service_id) {
PackageService::create([
'group_id' => $packages_group->id,
'package_id' => $package->id,
'service_id' => $service_id
]);
}
}
// 4. Clear caches
clearCacheByPattern('package_get_list');
clearCacheByPattern('online_package_list');
3. Update Package
POST /admin/package/update
Additional Field: package_id
Backend Process:
- Update package fields
- Delete existing groups:
PackageServiceGroup::where('package_id', $id)->delete() - Delete existing services (cascade via foreign key)
- Recreate groups and services
- Clear all package caches
4. Delete Package
POST /admin/package/delete
Request: { "package_id": 5 }
Cascade Deletes:
- All
package_service_groupsentries - All
package_servicesentries (via ON DELETE CASCADE)
5. Get Purchase History
GET /admin/package/get-purchased-package
Response Structure:
{
"status": true,
"purchasedpackages": [
{
"id": 456,
"name": "Haircut Package - 5 Sessions",
"price": "150",
"service_expiration": 6,
"session": 1,
"delivery_date": "2024-12-01",
"expiry_date": "2025-06-01",
"customer": {
"fname": "Jane",
"lname": "Smith"
},
"total_qty": 5,
"total_used": 2,
"remaining_balance": 3,
"service_group": [...],
"history": [
{
"date": "2024-12-10",
"description": "Redeemed for Men's Haircut",
"service_id": "5"
},
{
"date": "2024-12-15",
"description": "Redeemed for Men's Haircut",
"service_id": "5"
}
]
}
]
}
Balance Calculation:
$total_qty = PurchasePackageServiceGroup::where('purchased_packages_id', $id)
->sum('qty');
$total_used = PurchasePackageHistory::where('purchased_packages_id', $id)
->where('service_id', '!=', 0)
->count();
$remaining_balance = $total_qty - $total_used;
User App Purchase Flow
Step 1: Package Listing
Page: /[slug]/packages/page.tsx
Features:
- Tab switcher: "Packages" / "Purchase History"
- Displays active packages
- Shows total services and pricing
- Session type indicator
Data Source: Redux packageInfo
const { packageInfo } = useSelector((state: RootState) => state.home);
Component: MembershipCardList with type="pack"
Step 2: Package Details
Page: /[slug]/packages/packagedetail/page.tsx
Displayed Information:
- Package name and price
- Expiration period
- Session type (Single/Multi-use)
- Service Groups:
- Group name (auto-generated: "Group 1", "Group 2")
- Quantity available in group
- Services included in group
- Group price
Service Group Display:
{package.service_group.map((group, index) => (
<div key={group.id} className="service-group">
<h3>Group {index + 1}</h3>
<p>Quantity: {group.qty} services</p>
<p>Price: ${group.price}</p>
<div className="services-list">
{group.services.map((service) => (
<ServiceCard
key={service.id}
name={service.service.name}
duration={service.service.duration}
regularPrice={service.service.price}
/>
))}
</div>
</div>
))}
Step 3: Purchase Flow (Unified with Gift Cards)
Page: /[slug]/gift-card/buy-card/page.tsx
Selection: selectedGift = "package"
Redux State:
dispatch(setSelectedGift("package"));
dispatch(setSelectedGiftCard(packageData));
Same Form Modes:
- "For Someone" - Gift to another person
- "For Myself" - Purchase for self
Package-Specific Data:
{
id: number,
name: string,
price: string,
service_expiration: number,
session: number, // 0 or 1
tax: number,
service_group: [
{
id: number,
qty: number,
price: string,
services: Service[]
}
]
}
Step 4: Review & Confirm
Page: /[slug]/gift-card/review-confirm/page.tsx
Price Calculation:
const calculateTotal = () => {
const subtotal = parseFloat(selectedPackage.price);
let tax = 0;
// Apply tax if enabled on package AND location has tax
if (
selectedPackage.tax === 1 &&
selectedLocation.service_tax
) {
tax = (subtotal * parseFloat(selectedLocation.service_tax)) / 100;
}
return {
subtotal: subtotal,
tax: tax,
total: subtotal + tax
};
};
Summary Display:
- Total services included (sum of all group quantities)
- Expiration date (calculated from delivery date + months)
- Session type
- Service group breakdown
- Price with tax
Creating the Sale (createPackageSell API):
const handlePurchase = async () => {
const expiryDate = selectedPackage.service_expiration > 0
? moment(date).add(selectedPackage.service_expiration, 'months').format('YYYY-MM-DD')
: null;
const payload = {
location_id: selectedLocation.id,
customer_id: customer.id,
package_id: selectedPackage.id,
name: selectedPackage.name,
price: selectedPackage.price,
service_expiration: selectedPackage.service_expiration,
session: selectedPackage.session,
tax: selectedPackage.tax,
buy_for: tabs === "For Someone" ? 1 : 0,
recipient_fname: tabs === "For Someone" ? firstName : null,
recipient_lname: tabs === "For Someone" ? lastName : null,
recipient_email: tabs === "For Someone" ? recipientEmail : null,
sender_name: tabs === "For Someone" ? sender : null,
sender_msg: tabs === "For Someone" ? message : null,
delivery_date: date,
expiry_date: expiryDate,
service_groups: selectedPackage.service_group.map(g => ({
qty: g.qty,
price: g.price,
services: g.services.map(s => s.service_id)
}))
};
const response = await createPackageSell(payload);
if (response.status) {
// Handle payment if needed
if (totalWithTax > 0) {
handleStripePayment();
} else {
router.push(`/${slug}/packages/confirmed`);
}
}
};
Step 5: Confirmation
Page: /[slug]/packages/confirmed/page.tsx
Displayed Information:
- Purchase confirmation
- Total services received
- Service group breakdown
- Expiration date
- "View My Packages" link
Backend Implementation
Package Purchase API
Endpoint: POST /online/create-package-sell
Controller: CustomerSalesController.php
Backend Process:
// 1. Create purchased package
$purchased_package = PurchasedPackage::create([
'sales_id' => $sale_id,
'name' => $request->name,
'price' => $request->price,
'service_expiration' => $request->service_expiration,
'session' => $request->session,
'tax' => $request->tax,
'buy_for' => $request->buy_for,
'recipient_fname' => $request->recipient_fname,
'recipient_lname' => $request->recipient_lname,
'recipient_email' => $request->recipient_email,
'sender_name' => $request->sender_name,
'sender_msg' => $request->sender_msg,
'delivery_date' => $request->delivery_date,
'expiry_date' => $request->expiry_date,
'customer_id' => $request->customer_id
]);
// 2. Copy service groups
foreach ($request->service_groups as $group) {
$purchased_group = PurchasePackageServiceGroup::create([
'purchased_packages_id' => $purchased_package->id,
'qty' => $group['qty'],
'price' => $group['price']
]);
// 3. Copy services to purchased package
foreach ($group['services'] as $service_id) {
PurchasedPackageService::create([
'purchased_packages_id' => $purchased_package->id,
'service_id' => $service_id
]);
}
}
// 4. Send notifications
send_package_email($purchased_package);
// 5. Clear caches
clearCacheByPattern('purchased_packages');
Package Redemption & Usage
Multi-Use Package Redemption
When a customer books an appointment:
Validation Logic
Backend: BookingController.php or QuickSalesController.php
// 1. Validate package exists and belongs to customer
$package = PurchasedPackage::where('id', $package_id)
->where('customer_id', $customer_id)
->first();
if (!$package) {
return response()->json(['status' => false, 'message' => 'Invalid package']);
}
// 2. Check expiration
if ($package->expiry_date) {
if (Carbon::now()->greaterThan(Carbon::parse($package->expiry_date))) {
return response()->json(['status' => false, 'message' => 'Package expired']);
}
}
// 3. Check remaining quantity
$total_qty = PurchasePackageServiceGroup::where('purchased_packages_id', $package->id)
->sum('qty');
$total_used = PurchasePackageHistory::where('purchased_packages_id', $package->id)
->where('service_id', '!=', 0)
->count();
$remaining = $total_qty - $total_used;
if ($remaining <= 0) {
return response()->json(['status' => false, 'message' => 'No sessions remaining']);
}
// 4. Check service eligibility
$allowed_services = PurchasedPackageService::where('purchased_packages_id', $package->id)
->pluck('service_id')
->toArray();
if (!in_array($selected_service_id, $allowed_services)) {
return response()->json(['status' => false, 'message' => 'Service not included in package']);
}
// 5. For multi-use, check session type
if ($package->session == 0) {
// Single-use: Must book all services at once
$required_service_count = count($allowed_services);
$booking_service_count = count($request->services);
if ($booking_service_count != $required_service_count) {
return response()->json([
'status' => false,
'message' => 'Single-use package requires all services to be booked together'
]);
}
}
// 6. Create usage history
PurchasePackageHistory::create([
'purchased_packages_id' => $package->id,
'sales_id' => $appointment->sales_id,
'description' => 'Redeemed for ' . $service->name,
'service_id' => $service->id,
'date' => now()
]);
// 7. Update payment record
AppointmentPayment::create([
'appointment_id' => $appointment->id,
'payment_type_id' => 5, // Package
'amount' => 0, // Paid via package
'paid_amount' => $service->price,
'pending_amount' => 0,
'txn_id' => 'PKG-' . $package->id,
'status' => 1
]);
Database Schema
Main Tables
1. packages (Master Database)
CREATE TABLE packages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
service_expiration INTEGER DEFAULT 0,
image VARCHAR(255) NULL,
price VARCHAR(255) NOT NULL,
price_for_payroll INTEGER DEFAULT 1,
session INTEGER DEFAULT 1,
tax INTEGER DEFAULT 0,
status BOOLEAN DEFAULT 1,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
2. package_service_groups (Master Database)
CREATE TABLE package_service_groups (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
package_id BIGINT NOT NULL,
qty INTEGER NOT NULL,
price INTEGER NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
);
Purpose: Define service groups within a package
3. package_services (Master Database)
CREATE TABLE package_services (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
group_id BIGINT NOT NULL,
package_id BIGINT NOT NULL,
service_id STRING NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES package_service_groups(id) ON DELETE CASCADE,
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE
);
Purpose: Link services to groups
Query Example:
SELECT
psg.id as group_id,
psg.qty,
psg.price,
ps.service_id,
s.name as service_name
FROM package_service_groups psg
JOIN package_services ps ON psg.id = ps.group_id
JOIN services s ON ps.service_id = s.id
WHERE psg.package_id = ?;
4. purchased_packages (Client Database)
CREATE TABLE purchased_packages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sales_id BIGINT,
name VARCHAR(255),
service_expiration INTEGER,
price VARCHAR(255),
price_for_payroll INTEGER,
session INTEGER,
tax INTEGER,
buy_for INTEGER DEFAULT 0,
recipient_fname VARCHAR(255) NULL,
recipient_lname VARCHAR(255) NULL,
recipient_email VARCHAR(255) NULL,
customer_id BIGINT,
sender_name VARCHAR(255) NULL,
purchased_from VARCHAR(255) NULL,
sender_msg VARCHAR(255) NULL,
delivery_date TIMESTAMP,
expiry_date TIMESTAMP NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
5. purchased_package_service_groups (Client Database)
CREATE TABLE purchased_package_service_groups (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
purchased_packages_id BIGINT NOT NULL,
qty INTEGER NOT NULL,
price INTEGER NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (purchased_packages_id)
REFERENCES purchased_packages(id)
ON DELETE CASCADE
);
6. purchased_package_services (Client Database)
CREATE TABLE purchased_package_services (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
purchased_packages_id BIGINT NOT NULL,
service_id STRING NOT NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (purchased_packages_id)
REFERENCES purchased_packages(id)
ON DELETE CASCADE
);
7. purchased_package_histories (Client Database)
CREATE TABLE purchased_package_histories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
purchased_packages_id BIGINT NOT NULL,
sales_id VARCHAR(255),
description VARCHAR(255),
service_id STRING,
date STRING,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (purchased_packages_id)
REFERENCES purchased_packages(id)
ON DELETE CASCADE
);
Purpose: Track each service redemption
Remaining Balance Query:
SELECT
pp.id,
pp.name,
SUM(ppsg.qty) as total_qty,
COUNT(pph.id) as total_used,
(SUM(ppsg.qty) - COUNT(pph.id)) as remaining
FROM purchased_packages pp
LEFT JOIN purchased_package_service_groups ppsg
ON pp.id = ppsg.purchased_packages_id
LEFT JOIN purchased_package_histories pph
ON pp.id = pph.purchased_packages_id
AND pph.service_id != '0'
WHERE pp.id = ?
GROUP BY pp.id;
Testing Guide
Test Scenario 1: Multi-Use Package
Setup:
- Create package: 5 haircuts for $150
- Set
session = 1(multi-use)
Test:
- Purchase package
- Book appointment with 1 haircut
- Verify remaining quantity = 4
- Book another appointment with 2 haircuts
- Verify remaining quantity = 2
Expected Result:
- Each redemption decrements quantity by 1
- Customer can mix and match appointments
- Total 5 services available
Test Scenario 2: Single-Use Package
Setup:
- Create package: Hair + Makeup + Nails
- Set
session = 0(single-use)
Test:
- Purchase package
- Try to book only Hair service
- Should fail with error
Expected Result:
- Error: "Single-use package requires all services to be booked together"
- Must select all 3 services in one booking
Test Scenario 3: Expiration
Setup:
- Package with 3-month expiration
- Purchased on 2024-01-01
- Expiry: 2024-04-01
Test:
- On 2024-03-15: Redemption should work
- On 2024-04-15: Redemption should fail
Expected Result:
- Before expiry: Success
- After expiry: Error "Package expired"
Test Scenario 4: Service Eligibility
Setup:
- Package includes: Services 5, 10, 15
- Customer tries to book Service 20
Test:
- Select package as payment
- Try to book Service 20
Expected Result:
- Error: "Service not included in package"
- Only services 5, 10, 15 allowed
Troubleshooting
###Issue: Package not showing in user app
Cause: status = 0
Solution:
UPDATE packages SET status = 1 WHERE id = ?;
Clear cache:
clearCacheByPattern('online_package_list');
Issue: Remaining quantity incorrect
Cause: Orphaned history entries or missing group records
Solution:
-- Check package structure
SELECT
pp.id,
pp.name,
SUM(ppsg.qty) as configured_qty,
COUNT(pph.id) as usage_count
FROM purchased_packages pp
LEFT JOIN purchased_package_service_groups ppsg ON pp.id = ppsg.purchased_packages_id
LEFT JOIN purchased_package_histories pph ON pp.id = pph.purchased_packages_id
WHERE pp.id = ?
GROUP BY pp.id;
Issue: Single-use package allows partial booking
Cause: Session type validation not implemented
Solution: Ensure backend validates:
if ($package->session == 0 && count($booking_services) != count($package_services)) {
return error('Must book all services together');
}
Best Practices
For Salon Owners
- Group Logically: Group similar services together (all massages in one group)
- Quantity Strategy: Offer 5-10 services for best value perception
- Expiration: 6-12 months is standard for multi-use packages
- Single-Use: Use for event packages (weddings, proms)
- Pricing: Discount 15-25% off individual service prices
For Developers
- Validate Groups: Ensure every package has at least 1 group
- Validate Services: Each group must have at least 1 service
- Check Expiry: Always validate before redemption
- Session Type: Enforce single-use restrictions properly
- History Tracking: Record every redemption for audit trail
API Reference Summary
| Endpoint | Method | Purpose |
|---|---|---|
/admin/package/index | GET | List all packages |
/admin/package/store | POST | Create package |
/admin/package/update | POST | Update package |
/admin/package/delete | POST | Delete package |
/admin/package/get-purchased-package | GET | Purchase history |
/online/create-package-sell | POST | Purchase package |
/online/get-customer-packages | GET | Customer's packages |
Related Documentation
- Gift Cards - Gift card system
- Memberships - Membership system
- Payment Integration - Stripe setup
- Database Schema - Complete database reference