Product Configuration Guide¶
This document explains how to configure products, commission items, and price versioning in Airo.
Core Concepts¶
┌─────────────────────────────────────────────────────────┐
│ Product │
│ ├── name: "Tandem Completo" │
│ ├── type: jump │
│ ├── price: R$1200 │
│ │ │
│ ├── ProductItems (commission breakdown) │
│ │ ├── Vaga Avião x2 R$400 → company │
│ │ ├── Taxa Tandem R$200 → company │
│ │ ├── Comissão TM Pilot R$300 → performer [TM] │
│ │ └── Comissão Camera R$300 → performer [CAM] │
│ │ │
│ └── ProductPrices (versioned history) │
│ ├── R$1000 [2026-01-01 → 2026-03-01) Past │
│ ├── R$1200 [2026-03-01 → NULL] Current │
│ └── R$1400 [2026-06-01 → NULL] Scheduled │
└─────────────────────────────────────────────────────────┘
Products define what a person is charged for (jump, rental, service, merchandise).
Product Items break down how the product price is distributed — who gets what. Items are optional. Products without items send all revenue to the company.
Product Prices track price changes over time. The billing engine uses the manifest date to resolve which price was active.
Recipient Types¶
Each product item has a recipient_type that controls where the money goes:
| recipient_type | jump_type_id | recipient_id | Who receives | When to use |
|---|---|---|---|---|
company |
— | — | Company (DZ) | Aircraft fees, handling fees |
performer |
Set | — | Staff matched by jump type | Package commissions (Tandem, AFF) |
performer |
Not set | — | Staff person in that slot | Add-on commissions (Camera, Coach) |
packer |
— | Person UUID | Specific person always | Fixed recipient (e.g., packer João) |
Two Commission Models¶
Package Model¶
Staff commissions are baked into the customer's product. The customer pays one all-inclusive price. Staff slots have no product of their own.
Commission items use jump_type_id to route money to the correct staff member.
┌──────────────────────────────────────────────┐
│ Product: "Tandem Completo" R$1200 │
│ │
│ Items: │
│ ├── Vaga Avião x2 R$400 → company │
│ ├── Taxa Tandem R$200 → company │
│ ├── Comissão TM Pilot R$300 → performer ──┼──→ matched via jump_type_id
│ └── Comissão Camera R$300 → performer ──┼──→ matched via jump_type_id
│ │
│ Group on manifest: │
│ ├── Maria (customer) Product: this │
│ ├── Paulo (TM-PILOT) Product: none │
│ └── Cam Guy (CAMERA) Product: none │
└──────────────────────────────────────────────┘
Billing:
Maria → debit R$1200 (pays everything)
Paulo → credit R$300 (TM-PILOT commission)
Cam → credit R$300 (CAMERA commission)
Add-On Model¶
Staff has their own product with their own price. The staff cost is split equally among payers in the group.
Commission items do NOT need jump_type_id — the staff person is already in the slot.
┌──────────────────────────────────────────────┐
│ Product: "AFF-7" R$400 │
│ Items: │
│ ├── Vaga Avião x2 R$200 → company │
│ ├── Taxa AFF R$100 → company │
│ └── Comissão JM R$100 → performer ──┼──→ matched via jump_type_id
│ │
│ Product: "Camera Jump" R$200 │
│ Items: │
│ ├── Vaga Avião R$80 → company │
│ └── Comissão Camera R$120 → performer │ ← no jump_type_id (add-on)
│ │
│ Group on manifest: │
│ ├── João (customer) Product: AFF-7 │
│ ├── Ricardo (AFF-JM) Product: none │
│ └── Cam Guy (CAMERA) Product: Camera │
└──────────────────────────────────────────────┘
Billing:
João → debit R$400 (AFF-7)
João → debit R$200 (Camera split 1/1)
Ricardo → credit R$100 (JM commission from AFF-7)
Cam → credit R$120 (Camera add-on commission)
Sample Product Configurations¶
Solo Jump (No commissions)¶
Simple product with no items. All revenue goes to the company.
Tandem Completo (Package — single payer, all-inclusive)¶
Product: "Tandem Completo" R$1200
├── Vaga Avião x2 R$400 company
├── Taxa Tandem R$200 company
├── Comissão Tandem Pilot R$300 performer [jump_type: TM-PILOT]
└── Comissão Camera R$300 performer [jump_type: CAMERA]
Group setup:
| Slot | Jump Type | paid_by_group | Product |
|---|---|---|---|
| Maria (customer) | Tandem | false | Tandem Completo |
| Paulo (staff) | TM-PILOT | true | — |
| Cam Guy (staff) | CAMERA | true | — |
Ledger on landing:
| Person | Type | Description | Amount |
|---|---|---|---|
| Maria | debit | Tandem Completo - Load #3 | -R$1200 |
| Paulo | credit | Comissão Tandem Pilot - Load #3, Group "Tandem - Maria" | +R$300 |
| Cam Guy | credit | Comissão Camera - Load #3, Group "Tandem - Maria" | +R$300 |
AFF-7 + Camera (Package + Add-on hybrid)¶
AFF commission is in the customer's product (package). Camera is a separate product (add-on).
Product: "AFF-7" R$400
├── Vaga Avião x2 R$200 company
├── Taxa AFF R$100 company
└── Comissão Jump Master R$100 performer [jump_type: AFF-JM]
Product: "Camera Jump" R$200
├── Vaga Avião R$80 company
└── Comissão Camera R$120 performer
Group setup:
| Slot | Jump Type | paid_by_group | Product |
|---|---|---|---|
| João (customer) | AFF | false | AFF-7 |
| Ricardo (staff) | AFF-JM | true | — |
| Cam Guy (staff) | CAMERA | true | Camera Jump |
Ledger on landing:
| Person | Type | Description | Amount |
|---|---|---|---|
| João | debit | AFF-7 - Load #5 | -R$400 |
| João | debit | Camera Jump - Load #5, Group "AFF - João" (1/1 share) | -R$200 |
| Ricardo | credit | Comissão Jump Master - Load #5, Group "AFF - João" | +R$100 |
| Cam Guy | credit | Comissão Camera - Load #5, Group "AFF - João" | +R$120 |
Coach with Multiple Athletes (Cost split)¶
Coach cost is split equally among all payers in the group.
Product: "Solo" R$150
(no items)
Product: "Coach Jump" R$250
├── Vaga Avião R$120 company
└── Comissão Coach R$130 performer
Group setup:
| Slot | Jump Type | paid_by_group | Product |
|---|---|---|---|
| Athlete 1 (customer) | Solo | false | Solo |
| Athlete 2 (customer) | Solo | false | Solo |
| Coach (staff) | COACH | true | Coach Jump |
Ledger on landing:
| Person | Type | Description | Amount |
|---|---|---|---|
| Athlete 1 | debit | Solo - Load #7 | -R$150 |
| Athlete 2 | debit | Solo - Load #7 | -R$150 |
| Athlete 1 | debit | Coach Jump - Load #7, Group "Fun Jump" (1/2 share) | -R$125 |
| Athlete 2 | debit | Coach Jump - Load #7, Group "Fun Jump" (1/2 share) | -R$125 |
| Coach | credit | Comissão Coach - Load #7, Group "Fun Jump" | +R$130 |
Tandem with Packer Fee (Static commission)¶
Packer commission always goes to a specific person.
Product: "Tandem Completo" R$1200
├── Vaga Avião x2 R$350 company
├── Taxa Tandem R$200 company
├── Comissão Tandem Pilot R$300 performer [jump_type: TM-PILOT]
├── Comissão Camera R$300 performer [jump_type: CAMERA]
└── Taxa Dobrador R$50 packer [recipient: João-UUID]
Ledger on landing:
| Person | Type | Description | Amount |
|---|---|---|---|
| Maria | debit | Tandem Completo - Load #3 | -R$1200 |
| Paulo | credit | Comissão Tandem Pilot | +R$300 |
| Cam Guy | credit | Comissão Camera | +R$300 |
| João (packer) | credit | Taxa Dobrador | +R$50 |
Price Versioning¶
Product prices are versioned with effective date ranges. The billing engine uses the manifest date (not the current date) to resolve the correct price.
Timeline for "Tandem Completo":
Jan 1 Mar 1 Jun 1
│── R$1000 ──│── R$1200 ──│── R$1400 ──→
Past Current Scheduled
Manifest on Feb 15 → billed at R$1000
Manifest on Apr 10 → billed at R$1200
Manifest on Jul 01 → billed at R$1400
How it works¶
- Each price version has
effective_fromandeffective_to(NULL = currently active) - Database constraint prevents overlapping date ranges
- When scheduling a future price change, the current version's
effective_tois set to the new version'seffective_from - The
product.pricefield always reflects the current active price for display
Commission amounts (not versioned)¶
Product item amounts (product_items.amount) are updated in place — no separate versioning. Since billing runs at landing time (same day), items always reflect current values. The ledger transaction captures the actual amount paid, serving as the historical record.
Billing Engine Flow¶
Load lands → ProcessLoadBilling(load, manifest_date)
│
├── 1. Filter billable slots (exclude no_show, cancelled)
├── 2. Load products with items for all slots
├── 3. Resolve versioned prices by manifest_date
│
├── 4. Ungrouped slots → direct debit to person
│
└── 5. For each group:
├── a. Identify payers (paid_by_group=false)
├── b. Identify paid slots (paid_by_group=true)
│
├── c. Charge each payer their own product
├── d. Split each paid slot's cost equally among payers
│ (last payer absorbs rounding remainder)
│
├── e. Add-on commissions:
│ Staff's own product → credit staff person
│ (performer items with no jump_type_id)
│
├── f. Package commissions:
│ Payer's product → credit matched staff
│ (performer items with jump_type_id → find staff slot)
│
└── g. Static commissions:
Any product → credit specific recipient
(non-company/performer items with recipient_id)
Idempotency¶
The billing engine checks for existing transactions before creating new ones:
- Slot charges: checked by reference_type=load_billing + slot ID
- Split charges: checked by reference_type=load_billing_split + metadata source_slot_id
- Commissions: checked by reference_type=load_commission + product_item_id
If a load is re-landed, no duplicate charges are created.
Aircraft Payback (Repasse pro avião)¶
When a load lands, slots whose product has bills_aircraft = true generate a payback to the aircraft owner. The amount is resolved per slot, per product using this priority:
1. AircraftProductPayback override (aircraft + product, active on manifest_date)
→ use override.payback_amount
2. Otherwise, fall back to AircraftSlotPrice (aircraft default for manifest_date)
→ use slotPrice.price
3. Neither configured → skip (no payback transaction for this slot)
Example¶
Load with mixed products on aircraft PT-XXX (default R$ 215/vaga, overrides: Treino=135, Staff=180):
| Slot | Product | bills_aircraft | Resolution |
|---|---|---|---|
| 1 | Vaga 14k | true | default → R$ 215 |
| 2 | Vaga 14k | true | default → R$ 215 |
| 3 | Vaga Treino | true | override → R$ 135 |
| 4 | Vaga Staff | true | override → R$ 180 |
| 5 | Vaga sem repasse | false | skip |
Total payback = 215+215+135+180 = R$ 745 (credited to aircraft owner's wallet via aircraft_payback transaction).
Transaction metadata¶
The aircraft_payback transaction stores a per-product breakdown in metadata:
{
"billing_type": "aircraft_payback",
"load_id": "...",
"aircraft_id": "...",
"by_product": [
{ "product_id": "...", "product_name": "Vaga 14k", "slots_used": 2, "unit_payback": "215.00", "subtotal": "430.00", "source": "default" },
{ "product_id": "...", "product_name": "Vaga Treino", "slots_used": 1, "unit_payback": "135.00", "subtotal": "135.00", "source": "override" },
{ "product_id": "...", "product_name": "Vaga Staff", "slots_used": 1, "unit_payback": "180.00", "subtotal": "180.00", "source": "override" }
],
"total_slots_used": 5,
"paying_slots_used": 4
}
source is "override" when the value came from AircraftProductPayback, "default" when it came from AircraftSlotPrice. The UI (manifest details, reports) uses this to render an "override" chip.
📌 Backwards compatibility: transactions created before this feature have
by_jump_typeinstead ofby_product. UI consumers should detect which key is present and render accordingly.
Versioning¶
Both AircraftSlotPrice and AircraftProductPayback are versioned by effective_from / effective_to. Billing resolves the active version using manifest_date (not today), so historical loads keep their original values when reprocessed.
Creating a new version closes the previous active one (effective_to = new.effective_from - 1) in the same DB transaction. A partial unique index on (tenant_id, aircraft_id, product_id) WHERE effective_to IS NULL enforces "at most one active override per (aircraft, product)".
Quick Reference: When to Use What¶
| Scenario | Product Items Needed? | Commission Model |
|---|---|---|
| Solo jump (no staff) | No | All revenue → company |
| Tandem (all-inclusive price) | Yes | Package (jump_type_id on items) |
| AFF (instructor included) | Yes | Package for JM |
| Camera added separately | Yes | Add-on (staff has own product) |
| Coach for group | Yes | Add-on (split among payers) |
| External coach (customer type, group-paid) | No | Cost split only, no commissions |
| Packer fee (fixed person) | Yes | Static (recipient_id) |