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 = resolve(AircraftTakeoffPrice, AircraftTakeoffPriceTiers, occupant_count)
Load landed with 0 occupants → amount = 0
The price is versioned (effective-dated) like the slot price, resolved by the manifest date.
Occupancy tiers (aircraft_takeoff_price_tiers, "faixas por ocupação") sit on top of the base price, which is the normal/full-load price. Each tier is an inclusive range min_occupants..max_occupants → price, effective-dated with the same half-open [effective_from, effective_to) semantics. Tiers are versioned as a set: PUT /aircrafts/{id}/takeoff-tiers closes every open tier at effective_from and inserts the new set in one transaction (an empty set removes all tiers from that date). A gist exclusion constraint guarantees active tiers of one aircraft never overlap in occupants, so at most one tier prices a load. Resolution (aircrafttakeoffprice.ResolveTakeoffPrice, mirrored in the report SQL):
occupant_count = slot rows with a person, status ∉ {no_show, cancelled} (NOT Σ slots_required)
tier = the tier active on manifest_date with min_occupants <= occupant_count <= max_occupants
amount = tier ? tier.price : base takeoff price (gaps between tiers pay the base)
Example: capacity 15, base 2250, tiers 13-14 → 2000 and 8-12 → 1800: 15/15 → 2250, 13/15 → 2000, 10/15 → 1800, 6/15 → 2250 (no tier covers it). Changing the set later does not touch landed loads until they are reprocessed; the ledger upsert is delta-aware, so a reprocess posts only the difference.
The transaction metadata carries payback_mode: "per_takeoff", takeoff_price (the applied amount), base_takeoff_price, occupant_count (people on board — slot rows, comparable to capacity), aircraft_capacity and, when a tier applied, tier_min_occupants + tier_max_occupants + tier_id. The wallet description reads Por Decolagem - {ocupantes}/{capacidade} with the suffix - Faixa {min}-{max} when a tier priced the load. 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)".
Condições de decolagem¶
Uma condição de decolagem é uma característica da decolagem (não do produto) que gera
acréscimo — hoje, o exemplo é o Corte (decolagem avulsa, fora da sequência mínima combinada com o
dono do avião). A DZ cadastra as condições em Configurações → Condições de decolagem e o manifesto
marca 0, 1 ou várias condições em cada load. Várias condições somam os seus acréscimos.
Esta seção é a regra única: cobrança, repasse, Repasse de Vagas e Operacional-Financeiro seguem exatamente o que está aqui.
Dois lados independentes¶
Uma condição pode mexer só no atleta, só no avião ou nos dois — cada lado tem cadastro próprio:
| Lado | Onde se configura | Sem cadastro |
|---|---|---|
| Atleta (receita) | Produto → Acréscimo cobrado do atleta (produto × condição; valor sobre o produto inteiro) | O atleta paga o preço normal (a DZ absorve) |
| Avião (custo) | Aeronave → Acréscimo repassado ao avião (aeronave × condição, por vaga ou por decolagem; + acréscimo por produto opcional, por vaga) | O repasse não muda |
Uma condição sem nenhum acréscimo cadastrado deixa cobrança e repasse idênticos a uma load sem condição.
Condições da vaga e "condição incluída"¶
Um produto pode declarar uma Condição incluída (ex.: produto Vaga Corte a R$ 305, que já é a vaga com Corte). Para cada vaga:
Atleta: acréscimo na mesma linha da vaga¶
base = preço do produto (versão vigente na data do manifesto)
acréscimos = para cada condição marcada na load, EXCETO a condição incluída do produto:
acréscimo (produto × condição) vigente na data do manifesto, se houver
valor = base + Σ acréscimos
- A condição incluída não é cobrada de novo — o preço do produto já a contém.
- Uma condição incluída não marcada na load não gera acréscimo para os outros produtos da load (o acréscimo do atleta só olha as condições marcadas).
- Uma linha por vaga na carteira: o acréscimo entra no mesmo lançamento da vaga, já com o
valor final. A descrição ganha o sufixo
· Corte(várias condições:· Corte, Noturna) e a composição (valor base + cada acréscimo) aparece no detalhe da transação. - Grupos: o rateio entre os pagantes usa o valor da vaga paga com o acréscimo.
- Complementos (extras da vaga) não recebem acréscimo.
- O acréscimo é 100% receita da DZ — não passa por itens/comissões do produto.
Avião — modo por vaga¶
Só vagas cujo produto repassa aeronave (igual ao repasse normal):
unitário = repasse normal da vaga (override do produto ou preço de vaga da aeronave)
extra = para cada condição da vaga (marcadas ∪ incluída):
acréscimo por produto (aeronave × condição × produto)
senão acréscimo padrão (aeronave × condição)
senão 0
unitário' = unitário + Σ extra
- A condição incluída dispara o acréscimo do avião mesmo que a load não esteja marcada: a Vaga Corte paga ao avião 205 + 50 = 255 sem precisar de override de 255.
- Exceção — valor próprio é final: se o produto com condição incluída tem valor de repasse próprio na aeronave (override por produto, ex.: Vaga Corte → 255), esse valor já é o repasse com a condição: o acréscimo da condição incluída não é somado (255, não 255 + 50). Outras condições da load continuam somando. O detalhe por produto mostra "já inclui {condição}".
- Continua um lançamento de repasse por load; a descrição ganha
· Cortequando algum acréscimo foi aplicado e o detalhe por produto mostra o unitário base e o extra de cada condição (padrão ou por produto).
Avião — modo por decolagem¶
valor = (faixa de ocupação ?? valor por decolagem) + Σ acréscimo padrão (aeronave × condição)
para cada condição em: condições marcadas ∪ condições incluídas dos produtos a bordo
- O acréscimo entra uma vez por condição por decolagem, por mais vagas que a usem.
- Uma Vaga Corte a bordo dispara o acréscimo da decolagem mesmo sem a load marcada.
- Só existe acréscimo quando a decolagem tem valor de repasse (load vazia não paga nada). O acréscimo por produto não se aplica neste modo.
- Na tela da aeronave aparece o valor resultante ("Decolagem com Corte: R$ …"), mas o que fica guardado é o acréscimo — assim ele compõe com as faixas de ocupação e com várias condições.
Versionamento e reprocessamento¶
- Acréscimos do produto e da aeronave são versionados por data, como os preços: vale a versão vigente na data do manifesto.
- O motor é delta-aware: marcar ou desmarcar uma condição e reprocessar lança só a diferença (ex.: uma linha de ajuste na carteira do atleta e do dono). O fluxo normal é marcar a condição antes do pouso — aí a vaga já nasce com uma linha só.
Exemplo (load com Corte, aeronave por vaga: padrão R$ 205, Corte +R$ 50)¶
| Vaga | Produto | Atleta paga | Avião recebe |
|---|---|---|---|
| Atleta | Salto 4000m (acréscimo Corte +R$ 55) | 250 + 55 = R$ 305 | 205 + 50 = R$ 255 |
| Atleta | Vaga Corte (R$ 305, condição incluída: Corte) | R$ 305 | 205 + 50 = R$ 255 |
| Tandem | Tandem Simples (sem acréscimo cadastrado) | preço normal | 205 + 50 = R$ 255 |
API de integração (leitura)¶
A API de integração (escopo Cobranças) expõe, por vaga de passageiro de duplo, o resumo financeiro do atendimento a partir da carteira do passageiro: cobrança da vaga (produto, acréscimos de condição, complementos), outros débitos e transferências (ex.: fotos), pagamentos (sinal do Bookeo, Pix, cartão, dinheiro, transferências recebidas), ajustes (descontos e correções) e o saldo. Considera o sinal da reserva (em qualquer data) e os lançamentos da carteira desde o dia do salto — no primeiro salto da pessoa, desde o início da carteira — até o próximo salto dela; o que fica entre dois saltos conta para o anterior. Dinheiro devolvido ao passageiro (saque) aparece como pagamento negativo, para que o saldo do resumo seja igual ao da carteira. Pode mudar depois do salto (ex.: fotos pagas dias depois). É só leitura: não muda nenhum valor cobrado ou pago.
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) |