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) |
Payout Routing (person → organization wallet)¶
By default, a person-based payout (rta, performer) is credited to that person's
own wallet. Payout routing lets a person send specific payouts to a linked
organization's (CNPJ) wallet instead — e.g. route the RTA Pay to their company,
but keep the instructor commission personal.
- Routing key =
(person, item template). Each route is per item template, so different roles (camera vs instructor — different templates) are separated naturally. - Linked orgs only. The target party must be an organization the person is linked
to (
person_organizations). No route = personal wallet (no row is created for "personal"). - Routable types only:
rtaandperformer. The others (agency,static_party,equipment,company) already target a specific party and are never routed.packeris paid in the pack-confirmation flow, outside the template engine. - In the resolver (
resolveRecipientPartyID): it now returns the natural earner (EarnerPersonID/EarnerName) alongside the credit party. If a route(EarnerPersonID, templateID)exists, the credit party is replaced by the org's party; the earner is unchanged. - Attribution is preserved. The credit lands in the org's wallet, but the transaction
metadata records
earned_by_person_id/earned_by_person_name, so per-person production reports stay correct. The description gets a "(via {Name})" suffix. - Idempotency. For
rta/performerthe stable identity is the earner, not the credited wallet (routing changes the wallet, not who earned).matchPaybackBucketmatches bytemplate_id+earned_by_person_idfor these; legacy transactions (noearned_by_person_id) fall back to matching byrecipient_party_id. Reprocessing an old non-routed load does not duplicate — no reprocess/reset needed. - Known limitation. Changing a person's route after a load is already billed does not move already-settled credits (manual reconciliation). Set the route before billing the load.
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) - Cam Guy | -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) - Coach | -R$125 |
| Athlete 2 | debit | Coach Jump - Load #7, Group "Fun Jump" (1/2 share) - Coach | -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)
Delta-aware reconcile (idempotency + corrections)¶
Billing is delta-aware, not skip-if-billed. Every reprocess recomputes the desired amount for each bucket and posts only the difference as an append-only adjustment — so re-landing or editing a slot keeps the athlete's balance correct without duplicating charges. Buckets:
- Slot charges:
reference_type=load_billing+ slot ID - Split charges:
reference_type=load_billing_split+ metadatasource_slot_id - Extra charges:
reference_type=load_billing_extra+ extra ID - Commissions:
reference_type=load_commission+product_item_id
For each bucket: delta = desired − sum(existing transactions). delta == 0 → no-op (re-landing an
unchanged load creates nothing). First entry → debit/credit; corrections → adjustment linked to the
bucket's root. Changing a slot's product/price to a cheaper one posts a refund adjustment; to a
more expensive one, an extra debit.
Reversal pass. After the live pass, reverseOrphanedCharges lists the load's charge/commission
transactions (via the load_id metadata) and reverses to zero any bucket the current run did not
re-affirm — covering a slot/extra that was removed, marked no_show/cancelled, or an athlete
reassigned to a new slot (estorna o atleta antigo, cobra o novo).
Triggers. Re-landing a load reprocesses billing. Editing a slot's product, or adding/removing an extra, on an already-landed load also reprocesses immediately so the correction lands at once.
Legacy guard. A slot carrying a legacy reference_type=load_slot charge (old allocation-time
build) is left untouched and skipped by the new path, so it is never double-billed.
Aircraft Payback (Repasse pro avião)¶
Each aircraft has a payout mode (payback_mode): per_slot (default) or per_takeoff. The mode determines how the payback to the owner is computed when a load lands. Both modes emit a single aircraft_payback transaction per load (same reference_type), so idempotency and the delta-aware reconcile work identically.
Mode per_takeoff (fixed per landed load)¶
The DZ pays the owner a fixed amount per landed load with ≥1 occupant, independent of how many seats are used or whether the products have bills_aircraft. Empty landed loads (0 occupants) pay nothing.
Load landed with >=1 occupant → amount = AircraftTakeoffPrice (active on manifest_date)
Load landed with 0 occupants → amount = 0
The price is versioned (effective-dated) like the slot price, resolved by the manifest date. The transaction metadata carries payback_mode: "per_takeoff", takeoff_price, occupant_count (people on board — slot rows, comparable to capacity) and aircraft_capacity, and the wallet description reads Por Decolagem - {ocupantes}/{capacidade}. There is no by_product breakdown in this mode.
Mode per_slot (per seat, per product)¶
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) |