Skip to main content

Deposit System

Complete technical implementation guide for the deposit payment system and cancellation policy features in Salonnz.

Overview

The deposit system allows salons to require partial upfront payments for appointments, reducing no-shows and securing bookings. The cancellation policy system ensures customers agree to salon terms before finalizing bookings.

Deposit System (deposit_enable)

What It Controls

The deposit_enable setting in frontend_settings table acts as a global master switch for deposit functionality:

  • Enabled (1): Deposits can be collected when services have payment_type = 1
  • Disabled (0): All deposits are disabled, regardless of service settings

How It Works

Three-Level Implementation

Frontend Implementation

Step 1: Settings Fetch

File: Frontend-Userapp-Web/app/[slug]/booking/page.tsx

// Fetch booking settings on page load
const { data } = await getBookingSettings();

// Store in Redux
dispatch(setBookingSettings(data));

Response Structure:

{
"deposit_enable": 1,
"save_card_enable": 1,
"pay_later_enable": 0,
"STRIPE_KEY": "pk_test_..."
}

Step 2: Deposit Calculation

File: Frontend-Userapp-Web/app/[slug]/booking/review-confirm/page.tsx:131-163

The checkDeposit() function runs automatically when the review page loads:

const checkDeposit = async () => {
let deposit_amount = 0;

// Loop through all selected services
services.forEach((service) => {
// Payment Type 1: Deposit
if (service.payment_type == 1) {
deposit_amount += parseInt(service.min_amount);
}

// Payment Type 3: Pay Later (collect full amount)
if (service.payment_type == 3) {
deposit_amount += parseInt(service.price);
dispatch(setPayLaterOption(true));
}
});

// Store calculated amount
dispatch(setDepositAmount(deposit_amount));

// Create Stripe payment intent if deposit required
if (deposit_amount > 0) {
createNewDeposit(deposit_amount);
}
};

Logic Breakdown:

Service payment_typemin_amountpriceDeposit Collected
0 (Pay at Salon)$10$50$0 (no deposit)
1 (Deposit)$10$50$10 (partial)
2 (Save Card)N/A$50$0 (card saved only)
3 (Pay Later)N/A$50$50 (full amount)

Example Calculation:

// Booking: Haircut ($35, payment_type=1, min_amount=$10) 
// + Facial ($60, payment_type=1, min_amount=$20)

deposit_amount = $10 + $20 = $30
total_price = $35 + $60 = $95
remaining_balance = $95 - $30 = $65 (pay at salon)

Step 3: Payment Intent Creation

File: Frontend-Userapp-Web/app/[slug]/booking/review-confirm/page.tsx:75-91

const createNewDeposit = async (deposit_amount: number) => {
await getToken({
user: customer.id,
location_id: selectedLocation.id,
amount: deposit_amount,
})
.then((response: any) => {
if (response.status) {
// Store Stripe client secret
dispatch(setPaymentToken(response.token));
}
})
.catch((error: any) => {
console.error("Error creating payment intent:", error);
});
};

Backend API: POST /payment/create-order

Controller: PaymentController.php::create()

public function create(Request $request)
{
$frontendSettings = FrontendSettings::first();
$stripe = new StripeClient($frontendSettings->STRIPE_SECRET);

$paymentIntent = $stripe->paymentIntents->create([
'amount' => $request['amount'] * 100, // Convert to cents
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
'description' => 'Appointment deposit payment',
'shipping' => [
'name' => $customer->fname . ' ' . $customer->lname,
'address' => [
'line1' => $location->address_line_1,
'city' => $location->city,
'state' => $location->state,
'postal_code' => $location->zipcode,
'country' => 'US'
]
]
]);

return response()->json([
'status' => true,
'token' => $paymentIntent->client_secret
]);
}

Step 4: Payment Method Display

File: Frontend-Userapp-Web/app/_components/PaymentMethod/PaymentMethod.tsx:24-87

The component automatically determines which payment options to show:

useEffect(() => {
if (services.length > 0) {
services.forEach((service) => {
// Deposit option
if (
service.payment_type === 1 &&
bookingSettings?.deposit_enable === 1
) {
setPaymentType(1);
dispatch(setPaymentTypeValue(1));
dispatch(setPayBtn(true)); // Enable pay button
}

// Save Card option
else if (
service.payment_type === 2 &&
bookingSettings?.save_card_enable === 1
) {
setPaymentType(2);
dispatch(setPaymentTypeValue(2));
dispatch(setPayBtn(true));
}

// Pay Later option
else if (
service.payment_type === 3 &&
bookingSettings?.pay_later_enable === 1
) {
setPaymentType(3);
dispatch(setPaymentTypeValue(3));
dispatch(setPayBtn(false)); // No payment needed now
}
});
}
}, [services, bookingSettings]);

Rendered UI:

{/* Option 1: Pay at Salon */}
{(paymentType === 0 || paymentType === 3) && (
<div onClick={() => dispatch(setPaymentMethod(0))}>
<Icon icon="tabler:credit-card" />
<h4>Pay at Salon</h4>
</div>
)}

{/* Option 2: Add Deposit */}
{(paymentType === 1 || paymentType === 3) && (
<div onClick={() => dispatch(setPaymentMethod(1))}>
<Icon icon="ph:plus-circle-fill" />
<p>Add Deposit</p>
<p>To secure your appointment add deposit</p>

{/* Expand Stripe payment form when selected */}
{paymentMethod === 1 && depositAmount > 0 && (
<Card /> {/* Stripe Elements */}
)}
</div>
)}

{/* Option 3: Save Card */}
{paymentType === 2 && (
<div onClick={() => dispatch(setPaymentMethod(2))}>
<Icon icon="tabler:credit-card" />
<h4>Save Card</h4>
<Card />
</div>
)}

Step 5: Stripe Payment Confirmation

File: Frontend-Userapp-Web/app/_components/PaymentMethod/CheckoutForm.jsx

const handleSubmit = async (e) => {
e.preventDefault();

if (!stripe || !elements) return;

// For deposit payments (payment_type = 1)
if (paymentType === 1) {
const { paymentIntent, error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/${slug}/booking/confirmed`
},
redirect: 'if_required'
});

if (error) {
console.error("Payment failed:", error.message);
setMessage(error.message);
}
else if (paymentIntent.status === 'succeeded') {
// Update appointment status
await updatePaymentStatus({
appointment: apptId,
payment_intent: paymentIntent.id
});

// Navigate to confirmation
router.push(`/${slug}/booking/confirmed`);
}
}

// For save card (payment_type = 2)
else if (paymentType === 2) {
const { setupIntent, error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: confirmationUrl
},
redirect: 'if_required'
});

if (error) {
console.error("Card save failed:", error.message);
}
else if (setupIntent.status === 'succeeded') {
// Save payment method ID for future use
// ...
}
}
};

Backend Implementation

Appointment Creation with Deposit

File: BookingController.php::saveBooking()

When an appointment is created, the deposit details are saved:

// Create appointment record
$appointment = Appointment::create([
'location_id' => $request->location_id,
'customer_id' => $request->customer_id,
'date' => $request->date,
'time' => $request->time,
'status' => 0, // Pending payment
'deposit_amount' => $deposit_amount
]);

// Create payment records
if ($deposit_amount > 0) {
// Deposit payment (type_id = 1)
AppointmentPayment::create([
'appointment_id' => $appointment->id,
'payment_type_id' => 1,
'amount' => $deposit_amount,
'paid_amount' => 0, // Not paid yet
'pending_amount' => $deposit_amount,
'status' => 0
]);

// Remaining balance (type_id = 0 - Pay at Salon)
AppointmentPayment::create([
'appointment_id' => $appointment->id,
'payment_type_id' => 0,
'amount' => $total_price - $deposit_amount,
'paid_amount' => 0,
'pending_amount' => $total_price - $deposit_amount,
'status' => 0
]);
}

Payment Status Update

File: PaymentController.php::updatePaymentStatus()

After Stripe confirms payment:

public function updatePaymentStatus(Request $request)
{
$stripe = new StripeClient($frontendSettings->STRIPE_SECRET);
$output = $stripe->paymentIntents->retrieve($request->payment_intent);

$appointment = Appointment::where('appointment_id', $request->appointment)
->first();

if ($output->status == 'succeeded') {
// Update appointment status
$appointment->update(['status' => 1]); // Confirmed

// Update deposit payment record
$payment = AppointmentPayment::where('appointment_id', $appointment->id)
->where('payment_type_id', 1);

if ($payment->exists()) {
$payment->update([
'paid_amount' => $output->amount / 100, // Convert from cents
'pending_amount' => 0,
'txn_id' => $output->client_secret,
'status' => 1 // Paid
]);
} else {
// Create new payment record
AppointmentPayment::create([
'appointment_id' => $appointment->id,
'payment_type_id' => 1,
'amount' => $output->amount / 100,
'paid_amount' => $output->amount / 100,
'pending_amount' => 0,
'txn_id' => $output->client_secret,
'status' => 1
]);
}

// Send confirmation emails
// ... (email logic)
}
else {
// Payment failed
$appointment->update(['status' => 5]); // Payment Failed

AppointmentPayment::where('appointment_id', $appointment->id)
->where('payment_type_id', 1)
->update(['status' => 2]); // Failed
}
}

Database Schema

appointment_payments Table

CREATE TABLE appointment_payments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
appointment_id BIGINT NOT NULL,
payment_type_id INT NOT NULL, -- 0=Pay at Salon, 1=Deposit, 2=Card on File
amount DECIMAL(10,2), -- Total amount for this payment type
paid_amount DECIMAL(10,2), -- Amount actually paid
pending_amount DECIMAL(10,2), -- Amount remaining
txn_id VARCHAR(255), -- Stripe payment intent ID
status INT DEFAULT 0, -- 0=Pending, 1=Paid, 2=Failed
created_at TIMESTAMP,
updated_at TIMESTAMP,

FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE CASCADE
);

Example Data:

-- Appointment with $30 deposit, $95 total
-- Appointment ID: 1234

INSERT INTO appointment_payments VALUES
(1, 1234, 1, 30.00, 30.00, 0.00, 'pi_3O8...', 1, NOW(), NOW()), -- Deposit paid
(2, 1234, 0, 65.00, 0.00, 65.00, NULL, 0, NOW(), NOW()); -- Balance pending

Cancellation Policy (cancellation_key)

What It Controls

The cancellation_key field determines if customers must agree to cancellation terms before booking:

  • Enabled (1): Checkbox appears on review page; must be checked to proceed
  • Disabled (0): No policy agreement required

Frontend Implementation

Checkbox Display

File: Frontend-Userapp-Web/app/[slug]/booking/review-confirm/page.tsx:409-424

{frontendSettings?.cancellation_key == 1 && (
<div
className="policies_check_box selected"
onClick={() => dispatch(setCancellationPolicy(!cancellationPolicy))}
>
<RadioSelector
isActive={cancellationPolicy || false}
checkCondition={true}
size={20}
checkSize={12}
/>
I agree to cancel policies.
</div>
)}

Visual Appearance:

☐ I agree to cancel policies.

When clicked:

☑ I agree to cancel policies.

Policy Content Display

File: Frontend-Userapp-Web/app/[slug]/booking/review-confirm/page.tsx:442-448

Below the checkbox, the full policy text is displayed:

{depositPolicyStore && (
<div
className="deposit_policy"
dangerouslySetInnerHTML={{ __html: depositPolicyStore }}
/>
)}

Fetching Policy (lines 169-182):

useEffect(() => {
const fetchData = async () => {
if (!depositPolicyStore) {
try {
setPolicyLoad(true);
const data = await getAppContent(4); // Type 4 = deposit_policy

if (data.status) {
setDepositPolicy(data.data?.deposit_policy);
dispatch(setDepositPolicyStore(data.data?.deposit_policy));
}
} catch (error) {
console.error("Error fetching policy:", error);
} finally {
setPolicyLoad(false);
}
}
};

fetchData();
}, []);

Validation Before Booking

File: Frontend-Userapp-Web/app/[slug]/booking/layout.tsx:186-190

const handleNavigation = (path: string) => {
// Check if cancellation policy must be agreed to
if (frontendSettings?.cancellation_key != 1) {
router.push(path);
}
else if (
frontendSettings?.cancellation_key == 1 &&
cancellationPolicy
) {
router.push(path);
}
else {
toast.error("Please agree to cancellation policy");
return;
}
};

Validation Logic:

  • cancellation_key = 0: Proceed without check
  • cancellation_key = 1 AND cancellationPolicy = true: Proceed
  • cancellation_key = 1 AND cancellationPolicy = false: Show error

Backend Implementation

Policy Storage

Table: business_contact_details

CREATE TABLE business_contact_details (
id BIGINT PRIMARY KEY,
deposit_policy LONGBLOB, -- HTML content
refund_policy LONGBLOB, -- HTML content (future)
terms_conditions LONGBLOB,
created_at TIMESTAMP,
updated_at TIMESTAMP
);

File: Backend-Laravel/database/migrations/...add_deposit_policy...

Schema::table('business_contact_details', function (Blueprint $table) {
$table->binary('deposit_policy')->nullable();
$table->binary('refund_policy')->nullable();
});

Policy Retrieval API

Endpoint: GET /business-contact-details?type=4

Controller: BookingContactController.php

public function getAppContent(Request $request)
{
$type = $request->type;

switch ($type) {
case 4:
// Deposit policy
$content = BusinessContactDetails::select('deposit_policy')
->first();

return response()->json([
'status' => true,
'data' => $content
]);

case 5:
// Refund policy
$content = BusinessContactDetails::select('refund_policy')
->first();

return response()->json([
'status' => true,
'data' => $content
]);

default:
return response()->json([
'status' => false,
'message' => 'Invalid type'
]);
}
}

Additional Checkboxes in Review Page

Current Implementation

As of now, there is only ONE checkbox on the review-confirm page:

  1. Cancellation Policy Checkbox (cancellation_key)

Potential Additional Checkboxes

Based on the codebase structure, these could be added in future:

  1. Refund Policy Checkbox (not yet implemented)
{frontendSettings?.refund_policy_key == 1 && (
<div onClick={() => dispatch(setRefundPolicy(!refundPolicy))}>
<RadioSelector isActive={refundPolicy} />
I agree to refund policies.
</div>
)}
  1. Terms & Conditions Checkbox (not yet implemented)
{frontendSettings?.terms_key == 1 && (
<div onClick={() => dispatch(setTermsAgreed(!termsAgreed))}>
<RadioSelector isActive={termsAgreed} />
I agree to terms and conditions.
</div>
)}
  1. Marketing Consent Checkbox (not yet implemented)
<div onClick={() => dispatch(setMarketingConsent(!marketingConsent))}>
<RadioSelector isActive={marketingConsent} />
I agree to receive promotional emails and SMS.
</div>

Implementation Status Summary

✅ Fully Implemented Features

FeatureFrontendBackendDatabaseStripe Integration
Deposit calculation
Payment intent creationN/A
Deposit payment via Stripe
Payment status tracking
AppointmentPayment recordsN/A
Cancellation policy checkboxN/A
Policy content displayN/A
Email notificationsN/AN/A

📊 Deposit System Coverage

✅ 100% - Service-level deposit configuration (payment_type, min_amount)
✅ 100% - Global deposit enable/disable switch
✅ 100% - Automatic deposit calculation
✅ 100% - Stripe payment integration
✅ 100% - Payment confirmation flow
✅ 100% - Appointment status updates
✅ 100% - Payment record keeping
✅ 100% - Email confirmations

📋 Policy System Coverage

✅ 100% - Cancellation policy toggle
✅ 100% - Policy content management
✅ 100% - Policy display on review page
✅ 100% - Checkbox validation
✅ 100% - Policy acceptance tracking
⚠️ 50% - Refund policy (database ready, UI not implemented)
❌ 0% - Terms & conditions checkbox
❌ 0% - Marketing consent tracking

Configuration Examples

Example 1: Require $20 Deposit for Expensive Services

Admin Panel Actions:

  1. Enable deposit globally: deposit_enable = 1
  2. For each expensive service:
    • Set payment_type = 1 (Deposit)
    • Set min_amount = 20

Result: Customers must pay $20 upfront, remaining balance at salon


Example 2: Disable All Deposits

Admin Panel Actions:

  1. Set deposit_enable = 0

Result: Even if services have payment_type = 1, no deposits are collected


Example 3: Require Policy Agreement

Admin Panel Actions:

  1. Set cancellation_key = 1
  2. Add policy HTML in Business Contact Details

Result: Checkbox appears; customers cannot proceed without checking it


Example 4: Mixed Payment Types

Services Configuration:

  • Haircut: payment_type = 0 (Pay at Salon)
  • Massage: payment_type = 1, min_amount = 30 (Deposit)
  • Facial: payment_type = 3 (Pay Later - full amount)

Booking Calculation:

Haircut ($50):  No deposit
Massage ($80): $30 deposit
Facial ($60): $60 deposit (full amount for pay later)

Total deposit collected: $30 + $60 = $90
Remaining balance: $50 (haircut) + $50 (massage balance) = $100
Payment at salon: $100

Testing Guide

Test Scenario 1: Deposit Flow

  1. Setup:

    • Set deposit_enable = 1
    • Create service with payment_type = 1, min_amount = 10
  2. Test:

    • Book appointment with the service
    • Verify deposit calculation shows $10
    • Complete Stripe payment
    • Check appointment_payments table has 2 records:
      • Type 1 (Deposit): $10, status=1
      • Type 0 (Balance): $40, status=0
  3. Expected Result: Appointment status = 1 (Confirmed)


Test Scenario 2: Cancellation Policy

  1. Setup:

    • Set cancellation_key = 1
    • Add HTML policy content
  2. Test:

    • Navigate to review page
    • Verify checkbox is visible
    • Try to proceed without checking
    • Verify error message appears
    • Check the box and proceed
  3. Expected Result: Can only proceed when checked


Test Scenario 3: Deposit Disabled

  1. Setup:

    • Set deposit_enable = 0
    • Service still has payment_type = 1
  2. Test:

    • Book appointment
    • Verify no payment options shown
    • Appointment created with status = 0
  3. Expected Result: No deposit collected


Troubleshooting

Issue: Deposit Not Calculating

Causes:

  1. deposit_enable = 0 in frontend_settings
  2. Service payment_type ≠ 1
  3. Service min_amount not set or = 0

Solution:

-- Check settings
SELECT deposit_enable FROM frontend_settings;

-- Check service configuration
SELECT id, name, payment_type, min_amount
FROM services
WHERE id = <service_id>;

Issue: Policy Checkbox Not Showing

Causes:

  1. cancellation_key = 0
  2. Policy content empty

Solution:

-- Check setting
SELECT cancellation_key FROM frontend_settings;

-- Check policy content
SELECT deposit_policy FROM business_contact_details;

Issue: Payment Failing

Causes:

  1. Invalid Stripe keys
  2. Amount = 0
  3. Currency mismatch

Solution:

// Frontend: Check amount before payment
console.log("Deposit amount:", depositAmount);

// Backend: Check Stripe logs
tail -f storage/logs/laravel.log | grep stripe

Best Practices

For Salon Owners

  1. Set Reasonable Deposits:

    • 20-50% of service cost
    • Higher for long/expensive services
    • Lower to reduce booking friction
  2. Clear Policy Language:

    • State refund conditions
    • Specify notice period for cancellations
    • Explain no-show charges
  3. Test Payment Flow:

    • Use Stripe test mode
    • Complete full booking
    • Verify email notifications

For Developers

  1. Always Check Both Levels:

    if (
    bookingSettings.deposit_enable === 1 &&
    service.payment_type === 1
    ) {
    // Collect deposit
    }
  2. Handle Errors Gracefully:

    try {
    await createPaymentIntent();
    } catch (error) {
    console.error(error);
    toast.error("Payment setup failed. Please try again.");
    }
  3. Cache Policy Content:

    • Store in Redux to avoid repeated API calls
    • Clear cache when admin updates policy