Skip to main content

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

FieldTypeDescription
nameVARCHARPackage name (e.g., "Spa Deluxe Package")
priceDECIMALTotal package price
service_expirationINTEGERExpiration in months (0 = never)
sessionTOGGLE0 = Single-use, 1 = Multi-use
taxTOGGLEApply service tax (0/1)
price_for_payrollSELECT1 = Regular, 2 = Discount
statusTOGGLEActive (1) / Inactive (0)

Service Group Fields

Each package contains one or more service groups:

FieldTypeDescription
qtyINTEGERTotal quantity available in this group
priceDECIMALPrice for this group
services[]MULTI-SELECTServices 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, string
  • price: Required, numeric
  • service_expiration: Required, integer
  • service_group: Required array, minimum 1 group
  • service_group.*.qty: Required, integer, minimum 1
  • service_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:

  1. Update package fields
  2. Delete existing groups: PackageServiceGroup::where('package_id', $id)->delete()
  3. Delete existing services (cascade via foreign key)
  4. Recreate groups and services
  5. Clear all package caches

4. Delete Package

POST /admin/package/delete

Request: { "package_id": 5 }

Cascade Deletes:

  • All package_service_groups entries
  • All package_services entries (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:

  1. Purchase package
  2. Book appointment with 1 haircut
  3. Verify remaining quantity = 4
  4. Book another appointment with 2 haircuts
  5. 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:

  1. Purchase package
  2. Try to book only Hair service
  3. 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:

  1. On 2024-03-15: Redemption should work
  2. 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:

  1. Select package as payment
  2. 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

  1. Group Logically: Group similar services together (all massages in one group)
  2. Quantity Strategy: Offer 5-10 services for best value perception
  3. Expiration: 6-12 months is standard for multi-use packages
  4. Single-Use: Use for event packages (weddings, proms)
  5. Pricing: Discount 15-25% off individual service prices

For Developers

  1. Validate Groups: Ensure every package has at least 1 group
  2. Validate Services: Each group must have at least 1 service
  3. Check Expiry: Always validate before redemption
  4. Session Type: Enforce single-use restrictions properly
  5. History Tracking: Record every redemption for audit trail

API Reference Summary

EndpointMethodPurpose
/admin/package/indexGETList all packages
/admin/package/storePOSTCreate package
/admin/package/updatePOSTUpdate package
/admin/package/deletePOSTDelete package
/admin/package/get-purchased-packageGETPurchase history
/online/create-package-sellPOSTPurchase package
/online/get-customer-packagesGETCustomer's packages