Pular para conteúdo

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: rta and performer. The others (agency, static_party, equipment, company) already target a specific party and are never routed. packer is 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/performer the stable identity is the earner, not the credited wallet (routing changes the wallet, not who earned). matchPaybackBucket matches by template_id + earned_by_person_id for these; legacy transactions (no earned_by_person_id) fall back to matching by recipient_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.

Product: "Solo" R$150
Items: (none)

Billing: Person → debit R$150

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

  1. Each price version has effective_from and effective_to (NULL = currently active)
  2. Database constraint prevents overlapping date ranges
  3. When scheduling a future price change, the current version's effective_to is set to the new version's effective_from
  4. The product.price field 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 + metadata source_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_type instead of by_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)