# The Balenj event API, as an OpenAPI description, for a customer's developer and an AI assistant to read.
# A draft: nothing here is built yet. Served at https://atlas.balenj.com/developers/openapi.yaml beside the guide.
# The decisions it follows: docs/decisions/event-api.md; the draft: docs/proposals/event-api.md.
openapi: 3.1.0
info:
  title: Balenj event API (draft)
  version: 0.2.0-draft
  description: >
    A customer's own system sends each financial event to Balenj when it happens, in one request. Parties, items and
    labels come inside the event, with the customer's id and name, and are registered the first time they appear;
    names are tidied (ی/ک, spaces) and two with the same tidied name are one, each such merge listed in Balenj's
    panel and reported in the answer as the issue `merged_by_name`. A customer that may have different people of
    the same name adds something to the name that tells them apart. Money accounts are made by Balenj, which gives the customer their ids.

    An event is in one of three states. A `draft` is not sure to happen yet, counts nowhere and may be sent again
    changed; a month holding a draft is not closed. A `recorded` event counts in the reports and is never edited. A
    sale is `final` when its goods are delivered or its service given; a recorded sale becomes final by a
    `completion` event. A later happening (an instalment, a cost known later, a cancellation) is a new event that
    names the earlier one in `of`; a mistake is corrected in one request, the same id sent again whole with `edit` set. An event with money received
    on it (a live settlement linked to it) cannot be cancelled: such a cancellation is recorded without effect, with
    the issue `has_linked_events`, until those settlements are cancelled first. A cost linked to an event does not hold
    its cancellation back and stays with it. A final sale cannot be cancelled or corrected through the API (issue `final_sale`; its label
    is changed in Balenj's panel); money given
    back on it is a `return` without `cancel`. A correction reverses the whole earlier version, payments
    included, and the new version carries them anew; when the earlier version's month is closed, the difference lands
    in the current month. Linked events do not hold a correction back; they move to the new version. A party's or an item's name and details are taken from the
    latest event that carries them.

    Every event that arrives is recorded, even one with problems; Balenj sees them in its panel, reviews them and
    tells the customer when needed. The answer also lists them as `issues`. Only a wrong key, a body over 1 MB, too
    many requests or an error on Balenj's side records nothing. Amounts are whole rials. A date is Jalali
    (`1405/08/10`, `1405/08/10 14:30`) or Gregorian ISO 8601 (`2026-11-01`, `2026-11-01T14:30:00+03:30`), told apart
    by the year; a time without an offset is Tehran time. An event's id is unique among the events not cancelled: the
    same id sent again with the same content counts once; sent with other content and no `edit`, it is answered
    409 `duplicate_id` and kept by Balenj without effect, as a warning. A gateway's fee need not be sent: Balenj reads it from the gateway's own reports. For a
    customer subject to VAT, Balenj adds the `tax` charge to an event that comes without it, taken out of the price,
    marked as Balenj's calculation. Prices always include tax.
servers:
  - url: https://atlas.balenj.com/api/v1
    description: Proposed address. A test key and a live key work on the same address; events sent with a test key
      are kept apart and counted nowhere.
security:
  - key: []
paths:
  /events:
    post:
      summary: Send one event
      operationId: sendEvent
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Event" }
      responses:
        "200": { $ref: "#/components/responses/Received" }
        "401": { $ref: "#/components/responses/BadKey" }
        "409": { $ref: "#/components/responses/DuplicateId" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "429": { $ref: "#/components/responses/TooMany" }
        "5XX": { $ref: "#/components/responses/ServerError" }
    get:
      summary: List the events received, to check that nothing was lost
      operationId: listEvents
      parameters:
        - name: since
          in: query
          description: Only events Balenj received at or after this time.
          schema: { type: string }
        - name: cursor
          in: query
          description: The `next` value of the previous page.
          schema: { type: string }
      responses:
        "200":
          description: A page of at most 500 events, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/ReceivedEvent" }
                  next: { type: [string, "null"] }
        "401": { $ref: "#/components/responses/BadKey" }
        "429": { $ref: "#/components/responses/TooMany" }
        "5XX": { $ref: "#/components/responses/ServerError" }
  /events/batch:
    post:
      summary: Send up to 500 events in one request
      operationId: sendEvents
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                events:
                  type: array
                  maxItems: 500
                  items: { $ref: "#/components/schemas/Event" }
      responses:
        "200":
          description: Each event's answer, in the order sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/Answer" }
        "401": { $ref: "#/components/responses/BadKey" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "429": { $ref: "#/components/responses/TooMany" }
        "5XX": { $ref: "#/components/responses/ServerError" }
  /events/{id}:
    get:
      summary: Every event received with this id, with its issues
      description: A cancelled event and the event sent again in its place share an id; both are returned.
      operationId: getEvent
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The events with this id, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/ReceivedEvent" }
        "401": { $ref: "#/components/responses/BadKey" }
        "429": { $ref: "#/components/responses/TooMany" }
        "5XX": { $ref: "#/components/responses/ServerError" }
    delete:
      summary: Drop a draft
      description: Only a draft can be dropped. On a recorded event the answer carries the issue `not_a_draft` and
        nothing changes; a recorded event is cancelled with a `return` event instead.
      operationId: dropDraft
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { $ref: "#/components/responses/Received" }
        "401": { $ref: "#/components/responses/BadKey" }
        "429": { $ref: "#/components/responses/TooMany" }
        "5XX": { $ref: "#/components/responses/ServerError" }
components:
  securitySchemes:
    key:
      type: http
      scheme: bearer
      description: The customer's key, given by Balenj through a secure path. Never put it in code, a log or a chat.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Any unique text. The same key sent again within 24 hours returns the first answer and records nothing
        twice, so a request can be retried safely after a network error or a 5XX.
      schema: { type: string, maxLength: 255 }
  responses:
    Received:
      description: Recorded. `issues` lists what Balenj will review; an empty list means none was found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Answer" }
    BadKey:
      description: The key is missing or wrong. Nothing was recorded.
    DuplicateId:
      description: An event that is not cancelled already has this id, and this one has other content and no `edit`.
        It takes no effect; Balenj keeps it only as a warning. To correct the earlier event, send it again with
        `edit` true.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Answer" }
    TooLarge:
      description: The body is over 1 MB. Nothing was recorded; send fewer events at a time.
    TooMany:
      description: More than 20 requests in a second. Nothing was recorded; wait the seconds in Retry-After and send
        again.
      headers:
        Retry-After:
          description: Seconds to wait.
          schema: { type: integer }
    ServerError:
      description: An error on Balenj's side. Nothing was recorded; send again with the same Idempotency-Key.
  schemas:
    Event:
      type: object
      description: One financial event. `id`, `type` and `occurred_at` are expected on every event; an event without
        them is still recorded, with an issue.
      properties:
        id:
          type: string
          description: The customer's own id for the event, as text, such as an order number. Unique among the events
            that are not cancelled.
        type:
          type: string
          enum: [sale, prepayment, purchase_goods, purchase_equipment, running_expense, payment_to_state,
            owners_money_or_loan, own_transfer, settlement, return, completion]
          description: A `completion` says that the recorded sale named in `of` is now final. It carries nothing else.
        state:
          type: string
          enum: [draft, recorded, final]
          default: recorded
          description: "`final` is for a sale only. A draft is sent again with the same id to change it, sent with
            `recorded` or `final` to confirm it, or dropped with DELETE."
        occurred_at:
          type: string
          description: When it happened. Jalali as YYYY/MM/DD or YYYY/MM/DD HH:MM (1405/08/10, 1405/08/10 14:30), or
            Gregorian ISO 8601 as YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS with an offset (2026-11-01,
            2026-11-01T14:30:00+03:30). The calendar is told by the form, slashes and a year of 13xx or 14xx for Jalali,
            dashes and 20xx for Gregorian; anything else is recorded with the issue `unreadable_date`. Latin digits.
            Without an offset, Tehran time.
          examples: ["1405/08/10 14:30", "2026-11-01T14:30:00+03:30"]
        of:
          type: string
          description: The id of the earlier event this one follows (an instalment of a sale, a cost of an order, a
            sale becoming final, a cancellation). Once an event is corrected, the id names the event sent in its place.
        cancel:
          type: boolean
          description: With type `return` and `of`, cancels the whole earlier event.
        edit:
          type: boolean
          description: Marks a correction. The event is sent again whole, with the same id and `edit` true, and
            replaces the earlier version.
        party: { $ref: "#/components/schemas/Party" }
        description:
          type: string
          description: The customer's own words for the event, shown in the events list.
        sale_subject:
          type: string
          enum: [goods, subscription, freight_service, consultation]
          description: What a sale sold. Only on a sale.
        labels:
          type: object
          description: The customer's own labels, as label name to value, such as {"بخش":"غذا"}. A label left out is
            unknown for now, except on an event with `of`, which takes the labels of the event it follows unless it
            sends its own.
          additionalProperties: { type: string }
        lines:
          type: array
          description: The items of the invoice. On a sale, what the buyer bought; on a purchase, what the customer
            bought.
          items: { $ref: "#/components/schemas/Line" }
        charges:
          type: array
          description: The price and what was agreed around it. Nothing here is money that moved. On a sale, the
            `price` is the sum of the lines when lines are sent (a difference is the issue
            `lines_do_not_match_price`); a discount lowers what the buyer owes and a surcharge with direction `in`
            adds to it. A price, a discount and a reversal always include their tax; a `tax` charge says how much of
            them is tax.
          items: { $ref: "#/components/schemas/Charge" }
        instalments:
          type: array
          description: Only on a sale paid in instalments, its schedule. A down payment is instalment 1. Each instalment
            that arrives is a `settlement` naming the sale in `of`, with its number in the payment's `instalment`. A
            changed schedule is a correction (the sale sent again with `edit`).
          items:
            type: object
            required: [number, amount]
            properties:
              number: { type: integer, minimum: 1 }
              due_at: { type: string, description: "The due date, in the same formats as `occurred_at`." }
              amount: { type: integer, minimum: 0, description: Whole rials. }
        payments:
          type: array
          description: Money that moved, into or out of one of the customer's accounts, or a party's prepaid balance
            used.
          items: { $ref: "#/components/schemas/Payment" }
        metadata:
          type: object
          description: Text values kept with the event and shown only if asked for. At most 50 keys.
          additionalProperties: { type: string }
    Party:
      type: object
      description: The main party (the buyer of a sale, the seller of a purchase), or the party of one charge.
        Registered the first time its id appears.
      required: [id]
      properties:
        id: { type: string }
        name: { type: string }
        phone: { type: string }
        national_id:
          type: string
          pattern: "^[0-9]{10,11}$"
          description: Optional. A person's national code (10 digits) or a company's national id (11 digits), Latin
            digits and no dashes.
        role:
          type: string
          enum: [buyer, supplier, shop, carrier, line_operator, courier, reseller_or_influencer, marketplace,
            gateway, state, partner_or_lender]
    Item:
      type: object
      description: An item of goods or a service. Registered the first time its id appears.
      required: [id]
      properties:
        id: { type: string }
        name: { type: string }
        unit: { type: string, examples: ["عدد", "کیلوگرم"] }
        stocked: { type: boolean, description: "Kept in the customer's own stock." }
    Line:
      type: object
      properties:
        item: { $ref: "#/components/schemas/Item" }
        qty: { type: number }
        unit_price: { type: integer, minimum: 0, description: Whole rials. }
        labels:
          type: object
          description: Overrides the event's labels for this line.
          additionalProperties: { type: string }
        restock:
          type: boolean
          description: On a return, the goods came back into stock.
    Charge:
      type: object
      required: [kind, amount]
      properties:
        kind:
          type: string
          enum: [price, purchase_price, surcharge, discount, commission, payment_fee, damage, tax, reversal]
          description: The price of a sale; a purchase price (also a supplier's share or a carrier's fare); a
            surcharge such as freight or packaging; a discount; a commission; a gateway's fee (optional, Balenj reads
            it from the gateway); damage; tax, how much of the price is tax, only when known; a reversal of what was agreed.
        amount: { type: integer, minimum: 0, description: Whole rials. }
        party: { $ref: "#/components/schemas/Party" }
        line: { type: integer, minimum: 0, description: "The index of the line this charge belongs to, from 0." }
        reason:
          type: string
          enum: [freight, insurance, warehousing, packaging, loading, porterage, extra_services, own_service_fee]
          description: Only for a surcharge.
        direction:
          type: string
          enum: [in, out]
          description: Only for a surcharge. `in` when the customer charged it to the buyer, `out` when the customer
            paid it.
    Payment:
      type: object
      required: [amount, direction]
      properties:
        amount: { type: integer, minimum: 0, description: Whole rials. }
        direction:
          type: string
          enum: [in, out]
          description: "`in` when money came to the customer, `out` when it left. On a return, `out` gives money back
            to a buyer and `in` takes it back from a seller. With `wallet`, `in` pays a sale from the party's prepaid
            balance and `out` puts it back."
        account:
          type: string
          description: The id Balenj gave the money account. Left out together with `wallet`.
        wallet:
          type: boolean
          description: Paid from the party's prepaid balance, such as a wallet it topped up before. No money moves.
        at: { type: string, description: When this payment happened, if not when the event did. }
        instalment: { type: integer, minimum: 1, description: The instalment's number. }
        reference:
          type: string
          description: Optional. The gateway's reference or the bank's trace number, which helps Balenj link this
            payment to the transaction it reads itself, so the money is counted once.
        in_trust:
          type: boolean
          description: Money received in trust that is not the customer's, such as an escrow.
    Issue:
      type: object
      properties:
        code: { type: string, examples: ["missing_date", "lines_do_not_match_price", "unknown_of", "has_linked_events"] }
        field: { type: string, description: "Where in the event, such as `lines[1].item`." }
        message: { type: string, description: A short explanation in Persian. }
    Answer:
      type: object
      properties:
        received: { type: boolean, const: true }
        id: { type: [string, "null"] }
        issues:
          type: array
          items: { $ref: "#/components/schemas/Issue" }
    ReceivedEvent:
      type: object
      properties:
        received_at: { type: string }
        event: { $ref: "#/components/schemas/Event" }
        cancelled: { type: boolean }
        issues:
          type: array
          items: { $ref: "#/components/schemas/Issue" }
