Skip to main content

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

TypeWhenPurposeImplementation
DepositAt bookingSecure appointment slotInvoiceContext with category :deposit
BalanceAt checkoutRemaining service costInvoiceContext with category :balance
TipAfter serviceProvider gratuityInvoiceContext with category :tip
Full PaymentAt booking (no deposit)Pay entire amount upfrontInvoiceContext with category :full_pay
No-Show FeeIf customer doesn't arrivePenalty for missed appointmentInvoiceContext with category :no_show
RefundCancellation or disputeReturn funds to customerTransactionContext with type :refund
SubscriptionMonthly/AnnualRecurring membership billingSubscriptionContext (future)

Deposit Collection at Booking

Deposit Rules (Configurable per Merchant)

Default deposit percentages based on service amount:

Service AmountDeposit %Example
< $5050%$40 service → $20 deposit
$50 - $15030-40%$100 service → $40 deposit
> $15025-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:

OptionCalculationExample (on $100 service)
15%service_amount * 0.15$15
18%service_amount * 0.18$18
20%service_amount * 0.20$20
CustomUser-entered amountAny 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 InvoiceContext with 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) - PaymentProtocol implementation
  • 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 successfully
  • charge.failed - Payment failed (card declined, insufficient funds)
  • charge.refunded - Refund processed
  • payment_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:

  1. End customer adds card via Stripe Payment Element (PCI compliant)
  2. Stripe creates PaymentMethod and Customer
  3. SCP stores PaymentAccount with external_id = pm_abc123
  4. 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_id modification
  • 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

MetricDescription
Total BookingsAll appointments created
Zoca BookingsBookings from Zoca booking page
Total RevenueSum of all completed transactions
Zoca RevenueRevenue from Zoca-attributed bookings
Avg Booking ValueAverage total (service + tip) per appointment
Conversion RateBookings / 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_secret encrypted 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

See the API Reference for payment endpoints:

  • POST /api/payment-accounts - Add payment method
  • GET /api/invoices - List invoices
  • GET /api/transactions - List transactions
  • GET /api/transaction-journals - Double-entry ledger
  • POST /api/payments - Process payment