Merchant Finance
Comprehensive payment processing for appointments, deposits, tips, and subscriptions (EndCustomer → Merchant revenue). The SCP handles the complete billing lifecycle from booking deposit through final checkout with tip collection.
Payment Lifecycle: Deposit → Service → Balance → Tip
Features
Payment Methods
- Credit/Debit Cards - Visa, Mastercard, Amex, Discover (via Stripe, Square, Adyen)
- Digital Wallets - Apple Pay, Google Pay
- ACH Bank Transfers - For larger transactions or recurring billing
- Store Payment Methods - Cards and bank accounts saved on file
- Multiple Payment Methods - End customers can save multiple cards
Transaction Types
| Type | When | Purpose | Implementation |
|---|---|---|---|
| Deposit | At booking | Secure appointment slot | InvoiceContext with category :deposit |
| Balance | At checkout | Remaining service cost | InvoiceContext with category :balance |
| Tip | After service | Provider gratuity | InvoiceContext with category :tip |
| Full Payment | At booking (no deposit) | Pay entire amount upfront | InvoiceContext with category :full_pay |
| No-Show Fee | If customer doesn't arrive | Penalty for missed appointment | InvoiceContext with category :no_show |
| Refund | Cancellation or dispute | Return funds to customer | TransactionContext with type :refund |
| Subscription | Monthly/Annual | Recurring membership billing | SubscriptionContext (future) |
Deposit Collection at Booking
Deposit Rules (Configurable per Merchant)
Default deposit percentages based on service amount:
| Service Amount | Deposit % | Example |
|---|---|---|
| < $50 | 50% | $40 service → $20 deposit |
| $50 - $150 | 30-40% | $100 service → $40 deposit |
| > $150 | 25-30% | $200 service → $60 deposit |
Configuration:
- Merchants configure deposit policies in
PaymentPolicyContext - Can set fixed amounts or percentages
- Can exempt specific services from deposit requirements
- Can require card on file even if no deposit collected
Deposit Flow
# When booking appointment
AvailabilityContext.book_appointment(%{
end_customer_id: customer.id,
service_id: service.id,
provider_id: provider.id,
appointment_time: ~U[2025-01-20 14:00:00Z],
payment_account_id: card.id # Saved card
})
# Creates:
# 1. Appointment (status: pending_payment)
# 2. PaymentContract (links payment rules to appointment)
# 3. Invoice (category: :deposit, amount: calculated deposit)
# 4. Transaction (charge deposit via payment plugin)
# 5. Update Appointment (status: confirmed)
Stripe Integration:
- Uses Stripe Payment Element for PCI DSS compliance
- Supports 3D Secure (SCA) for European cards
- Saves payment method to Stripe customer for future charges
- Immediate charge for deposit (not authorization)
Final Payment at Checkout
Balance Collection
After service is completed:
Remaining Balance = Service Amount - Deposit Paid
Example:
- Service: $100 haircut
- Deposit paid at booking: $40
- Balance due at checkout: $60
Flow:
AppointmentContext.process_checkout(appointment, %{
payment_account_id: saved_card.id
})
# Creates:
# 1. Invoice (category: :balance, amount: $60)
# 2. Transaction (charge $60 via payment plugin)
# 3. Links to original PaymentContract
Edge Cases:
- If balance = $0 (deposit = full amount), skip balance charge
- If payment fails, retry with different card or decline checkout
- Merchant can adjust final amount (add products, discounts)
Tip Collection
Tip Options
Post-service, present tip options to end customer:
| Option | Calculation | Example (on $100 service) |
|---|---|---|
| 15% | service_amount * 0.15 | $15 |
| 18% | service_amount * 0.18 | $18 |
| 20% | service_amount * 0.20 | $20 |
| Custom | User-entered amount | Any amount |
| No Tip | $0 | $0 |
Implementation:
# After balance is collected
AppointmentContext.collect_tip(appointment, %{
tip_amount: Money.new(1500, :USD), # $15.00
payment_account_id: saved_card.id
})
# Creates:
# 1. Invoice (category: :tip, amount: $15)
# 2. Transaction (charge $15 via payment plugin)
# 3. Links to same PaymentContract
Tip Attribution:
- Tips are attributed to the specific provider
- Multi-provider appointments can split tips
- Merchants can configure auto-gratuity for large groups
Final Receipt
Receipt Contents
[Business Name]
[Location Address]
Date: Jan 20, 2025 at 2:00 PM
Service: Haircut & Style
Provider: Jane Smith
Duration: 60 minutes
Subtotal: $100.00
Deposit (paid): -$40.00
Balance: $60.00
Tip (18%): $18.00
------------------------
Total Paid: $78.00
Total Charged: $118.00
Payment Method: Visa ****1234
[View Full Receipt]
[Book Again]
[Leave Review]
Receipt Delivery
- Sent via SMS and Email immediately after checkout
- Stored in
InvoiceContextwith PDF generation - Accessible in customer's appointment history
- Includes itemized breakdown of all charges
Payment Policies
Merchants configure payment rules via PaymentPolicyContext:
Deposit Policies
- Require deposit: Yes/No
- Deposit amount: Fixed $ or percentage
- Deposit exceptions: Exempt services/providers
- Card on file: Require even if no deposit
Cancellation Policies
- >24h notice: Full refund (100% deposit)
- 12-24h notice: Partial refund (50% deposit)
- <12h notice: No refund (0% deposit)
- Custom windows: Merchants can customize timing
No-Show Policies
- Grace period: 15 minutes after appointment time
- No-show fee: Percentage of deposit (default 100%)
- Waive option: Merchants can manually waive fees
- Auto-charge: Charge saved payment method automatically
Payment Integration
Plugin-Based Architecture
Payments are handled through PluginContext with protocol-based polymorphism:
Supported Payment Processors:
- Stripe (Primary MVP) -
PaymentProtocolimplementation - Square (Future) - POS and payment processing
- Adyen (Future) - Enterprise gateway
Plugin Configuration:
# Merchant-level payment plugin
%Plugin{
type: :payment,
customer_id: merchant_org.id,
merchant_id: merchant.id, # Optional merchant-specific config
external_id: "acct_abc123", # Stripe Connected Account ID
external_username: nil,
external_password: nil, # API keys stored encrypted
plugin_defaults: %{
currency: "USD",
statement_descriptor: "SALON*",
capture_method: "automatic"
}
}
Stripe Connect Integration
Architecture:
- Platform uses Stripe Connect Standard accounts
- Each merchant has their own Stripe Connected Account
- Funds flow directly to merchant (not platform)
- Platform can charge application fees (future)
Webhook Handling: Stripe sends webhooks for payment events:
charge.succeeded- Payment completed successfullycharge.failed- Payment failed (card declined, insufficient funds)charge.refunded- Refund processedpayment_intent.payment_failed- 3DS authentication failed
Implementation:
# PaymentContext processes Stripe webhooks
PaymentContext.process_webhook(plugin, %{
"type" => "charge.succeeded",
"data" => %{
"object" => %{
"id" => "ch_abc123",
"amount" => 4000, # $40.00
"currency" => "usd",
"metadata" => %{
"invoice_id" => "...",
"appointment_id" => "..."
}
}
}
})
# Updates Transaction status to :completed
# Updates Invoice status to :paid
# Triggers confirmation SMS/Email
Payment Accounts (Cards on File)
PaymentAccount Schema
End customers can save multiple payment methods:
%PaymentAccount{
id: uuid,
end_customer_id: uuid, # Who owns this payment method
plugin_id: uuid, # Which payment processor (Stripe, Square)
external_id: "pm_abc123", # Stripe PaymentMethod ID
type: :card, # :card, :bank_account, :digital_wallet
brand: "visa", # Card brand
last4: "1234", # Last 4 digits
exp_month: 12,
exp_year: 2027,
is_default: true, # Default payment method
status: :active, # :active, :expired, :failed
metadata: %{
"stripe_customer_id" => "cus_xyz789",
"billing_address" => %{...}
}
}
Adding Payment Methods
Flow:
- End customer adds card via Stripe Payment Element (PCI compliant)
- Stripe creates PaymentMethod and Customer
- SCP stores PaymentAccount with
external_id = pm_abc123 - Creates Identifier linking EndCustomer to Stripe Customer ID
PaymentAccountContext.create_payment_account(%{
end_customer_id: customer.id,
plugin_id: stripe_plugin.id,
external_id: "pm_abc123", # From Stripe
type: :card,
brand: "visa",
last4: "1234",
exp_month: 12,
exp_year: 2027
})
# Also creates Identifier:
IdentifierContext.create_identifier(%{
identifiable_id: customer.id,
identifiable_type: "end_customer",
plugin_id: stripe_plugin.id,
identifier_type: "stripe_customer",
identifier_value: "cus_xyz789"
})
Transaction Ledger
All payment transactions are recorded in TransactionContext:
Transaction Schema
%Transaction{
id: uuid,
payment_account_id: uuid, # Which card/account was charged
plugin_id: uuid, # Which payment processor
amount: Money.new(4000, :USD), # $40.00
status: :completed, # :pending, :completed, :failed, :refunded
type: :charge, # :charge, :refund, :payout
external_id: "ch_abc123", # Stripe Charge ID
metadata: %{
"stripe_payment_intent" => "pi_...",
"invoice_id" => "...",
"appointment_id" => "..."
}
}
Double-Entry Bookkeeping
TransactionJournalContext ensures accounting integrity:
%TransactionJournal{
id: uuid,
credit_id: uuid, # Transaction ID (money coming in)
debit_id: uuid, # Transaction ID (money going out)
payment_contract_id: uuid, # Links to PaymentContract
metadata: %{
"description" => "Deposit for haircut appointment"
}
}
Example Journal Entry:
Debit: EndCustomer PaymentAccount (-$40) [debit_id]
Credit: Merchant PaymentAccount (+$40) [credit_id]
Immutability:
- PostgreSQL trigger prevents
owner_idmodification - Validates
credit_id ≠ debit_id - Ensures LHS = RHS (debits equal credits)
Refunds & Chargebacks
Refund Processing
Merchant-Initiated Refund:
AppointmentContext.cancel_appointment(appointment, %{
cancelled_by: :merchant,
reason: "Provider sick"
})
# Calculates refund based on cancellation policy
# Creates refund transaction
RefundContext.process_refund(%{
original_transaction_id: deposit_transaction.id,
amount: Money.new(4000, :USD), # Full deposit refund
reason: "Appointment cancelled by merchant"
})
# Creates:
# 1. Transaction (type: :refund, amount: $40)
# 2. Stripe API call to refund charge
# 3. TransactionJournal (reverse of original entry)
# 4. Update Invoice (status: :refunded)
Chargeback Handling:
- Stripe webhook:
charge.dispute.created - Update Transaction status to
:disputed - Notify merchant via email/SMS
- Merchant can submit evidence through dashboard
- If lost, Transaction status →
:refunded
Revenue Dashboard
Track Zoca-attributed revenue vs. total merchant revenue:
Metrics Tracked
| Metric | Description |
|---|---|
| Total Bookings | All appointments created |
| Zoca Bookings | Bookings from Zoca booking page |
| Total Revenue | Sum of all completed transactions |
| Zoca Revenue | Revenue from Zoca-attributed bookings |
| Avg Booking Value | Average total (service + tip) per appointment |
| Conversion Rate | Bookings / Booking page views |
| Deposit Collection Rate | % of bookings with deposit paid |
| Tip Rate | % of completed appointments with tips |
Attribution
Appointments include attribution metadata:
%Appointment{
source: :zoca_booking, # vs :manual, :phone, :walkin
attribution: %{
channel: "organic_search",
campaign: "google_ads_q1_2025",
booking_page_id: merchant.booking_page.id,
referrer: "https://google.com/search?q=salon+near+me"
}
}
Dashboard Queries:
# Zoca-attributed revenue this month
zoca_revenue = AppointmentContext.calculate_revenue(%{
source: :zoca_booking,
date_range: {start_of_month, end_of_month}
})
# Total revenue this month
total_revenue = AppointmentContext.calculate_revenue(%{
date_range: {start_of_month, end_of_month}
})
zoca_percentage = (zoca_revenue / total_revenue) * 100
Security & Compliance
PCI DSS Compliance
- No Raw Card Data: SCP never stores full card numbers or CVV
- Stripe Payment Element: Handles card input in iframe (PCI compliant)
- Tokenization: Only store Stripe PaymentMethod IDs
- Encrypted Fields:
external_password,totp_secretencrypted at rest - Audit Logs: All payment actions logged in
AuditContext
3D Secure (SCA)
- Automatically triggered for European cards
- Stripe handles authentication challenge
- Payment completes after customer verification
- Reduces fraud and chargeback risk
Fraud Detection
- Stripe Radar (built-in fraud detection)
- Decline payments flagged as high-risk
- Velocity checks (max transactions per hour)
- Merchant can block specific cards/IPs
Related Capabilities
- Appointments - Appointment booking with deposit collection
- Platform Finance - Platform billing (Merchant → Tenant)
Related API Endpoints
See the API Reference for payment endpoints:
POST /api/payment-accounts- Add payment methodGET /api/invoices- List invoicesGET /api/transactions- List transactionsGET /api/transaction-journals- Double-entry ledgerPOST /api/payments- Process payment