The pricing model
There are two fee models, picked automatically based on whether the venue has an active Stripe subscription:
- Referral percent— applied to subscribed venues. The default is
7%of the booking value, with a$1.50floor when the booking value is unknown. Every venue can override the percentage viavenue.referral_fee_pct. - Per-booking flat fee— applied to non-subscribers. A flat
$2.50per booking, regardless of booking value.
# Fee constants — keep in sync with pricing page and Stripe product config
DEFAULT_REFERRAL_FEE_PCT = 0.07 # 7% of booking value for subscribed venues
MIN_REFERRAL_FEE_USD = 1.50 # Floor when booking value unknown or zero
FLAT_FEE_USD = 2.50 # Non-subscriber per-booking flat fee
MAX_BOOKING_VALUE_USD = 1000 # Safety clamp for unreasonable values
class FeeCalc(BaseModel):
fee_type: str # referral_pct | per_booking_flat
fee_pct: float
platform_fee_usd: float
def calculate_booking_fee(
venue: Venue,
venue_client: VenueClient | None,
booking_value_usd: float | None = None,
) -> FeeCalc:
"""Calculate the platform fee for a booking."""
if booking_value_usd is not None:
if booking_value_usd < 0:
booking_value_usd = 0
if booking_value_usd > MAX_BOOKING_VALUE_USD:
booking_value_usd = MAX_BOOKING_VALUE_USD
if (
venue_client
and hasattr(venue_client, "stripe_subscription_id")
and venue_client.stripe_subscription_id
):
fee_pct = venue.referral_fee_pct or DEFAULT_REFERRAL_FEE_PCT
if booking_value_usd and booking_value_usd > 0:
fee = round(booking_value_usd * fee_pct, 2)
else:
fee = MIN_REFERRAL_FEE_USD
return FeeCalc(fee_type="referral_pct", fee_pct=fee_pct, platform_fee_usd=fee)
return FeeCalc(fee_type="per_booking_flat", fee_pct=0, platform_fee_usd=FLAT_FEE_USD)Safety clamps
Three clamps protect against data pollution and runaway billing:
- Negative booking value— clamped to zero. Logged as a warning so we can spot the upstream source of the bad data.
- Booking value over $1000 — clamped to
MAX_BOOKING_VALUE_USD. A single dinner reservation shouldn't generate a $200 platform fee because a decimal slipped. - Missing booking value — falls back to the
MIN_REFERRAL_FEE_USDfloor of $1.50. This keeps the flat fee path predictable even for venues that don't report booking value.
Stripe idempotency
Every PaymentIntent creation uses an idempotency key derived from the envelope ID. The key is stored in app/core/idempotency.py so retries never create duplicate charges. If the same envelope flows through collect_booking_fee twice (which happens on network retries), Stripe returns the existing charge instead of creating a new one.
The commerce ledger
Every fee collection writes a row into the commerce_ledger table. The row carries:
envelope_id— the A2A envelope that triggered the bookingvenue_client_id— the owner being chargedfee_type—referral_pctorper_booking_flatplatform_fee_usd— the calculated amountstripe_payment_intent_id— the Stripe referencestatus— one of theFeeStatusenum values (pending,collecting,collected,failed,waived)
Reconciliation
Two scheduler jobs keep the ledger honest:
retry_pending_fees— runs every 30 minutes. Retries rows stuck inpendingorfailedstatus with an exponential backoff.reconcile_fee_status— runs every 30 minutes. Compares local ledger status against Stripe's view and corrects drift (e.g. a charge that succeeded on Stripe but never marked the ledger as collected).
Both jobs are listed in full on the Scheduler page.
Behaviour in dev / staging
Outside production the Stripe PaymentIntent is skipped but the ledger row and envelope update are still written. This exercises the whole data pipeline end-to-end so you can test commerce flows without charging anyone. In production, if the Stripe call fails the ledger row lands in failed state and the retry job picks it up later.
Auditability
Every row in commerce_ledger is append-only. Corrections write a new row with a negative amount rather than mutating the original. This makes the ledger safe to replay for monthly invoices and gives us a clean audit trail if a venue disputes a charge.