User Request Flow
A user request asks to change or cancel a passenger's booked flight. The admin quotes it as a list of fees — a seat, a baggage or another charge. The requester then confirms the quote, choosing how much of each fee the company pays, or declines it. If the requester keeps a part for themselves, they pay it first; the company's approvers review the rest; and finally the admin applies each fee to the booking.
This page covers the status flow and the UserRequest object. Each operation has its own page
under APIs, and the errors are under
Errors.
What changed
- The quote is a list of fees.
process_user_requesttakesfeesinstead ofamount;amountis now their sum. Fees can't change once issued. - The split is per fee.
confirm_user_requesttakes the company's share of each fee (fees: [{ id, corporate_amount }]) instead of onecorporate_amount. - The requester pays their part in the app. A new status,
AWAITING_PERSONAL_PAYMENT, and a new operation,begin_personal_payment, which is paid from the requester's wallet through the store checkout — see How the requester pays. - Fees are applied one by one.
add_passenger_feetakesuser_request_fee_idwithuser_request_id, and uses the fee's own data and price — no more sending a price of 0. - Payment state moved.
extra_purchase_idis removed fromUserRequest. Each fee haspaidandextra_purchase_ids; the request haspaid_personal_amount. claimedonadmin_user_requestsnow means "every fee paid".
Status flow
The requester acts up to three times: to create the request, to confirm or decline the quote, and to pay their part. Status values come back upper case, as shown.
An admin can still resolve or reject without quoting; a quote is what triggers the requester's steps.
| Status | Meaning | Who acts next |
|---|---|---|
PENDING | Created, no admin yet | Admin picks it up; requester may cancel |
IN_CHARGE | An admin is working on it | Admin quotes, resolves or rejects; requester may cancel |
ISSUED | Admin quoted the fees; waiting for the requester | Requester confirms or declines |
AWAITING_PERSONAL_PAYMENT new | Confirmed, and the requester keeps a part; waiting for them to pay it | Requester pays — see How the requester pays |
CONFIRMED | Requester accepted, and their part (if any) is paid; waiting for approvers | Company approvers, level by level; every level must approve |
RESOLVED | Done | Admin applies each fee with add_passenger_fee |
REJECTED | Rejected by the admin or by an approver | Nobody (final) |
CANCELED | Requester withdrew before a quote | Nobody (final) |
DECLINED | Requester turned the quote down | Nobody (final) |
The requester pays before approval. Approvers only see requests whose personal part is already
paid. A request waiting in AWAITING_PERSONAL_PAYMENT can't be cancelled or declined, and the
admin can't process it.
CONFIRMED can skip straight to RESOLVED. When the company pays nothing, or the company's
policy needs no approval, the request resolves right after it reaches CONFIRMED. Mutations report
the status they set, so refetch the request before rendering its status.
Fees and the split
Each fee is one thing the passenger gets — a seat, a baggage or another charge — on one leg, priced
in the request's currency. The requester decides, fee by fee, how much the company pays.
| Amount | Where it lives | How it's worked out |
|---|---|---|
amount | Request | Sum of every fee's price, set when the admin issues the quote |
corporate_amount | Each fee | Chosen by the requester at confirm, from 0 to the fee's price |
corporate_amount | Request | Sum of the fees' corporate_amount |
personal_amount | Request | amount - corporate_amount: what the requester pays |
The two parts are paid at different times:
| Part | Paid by | When | Through |
|---|---|---|---|
personal_amount | Requester, from their wallet | After confirming, before approval | How the requester pays |
Each fee's corporate_amount | Company | When the admin applies that fee | add_passenger_fee, one call per fee |
How the requester pays
The requester pays personal_amount from their wallet, through the store checkout. FE starts the
payment with two calls — begin_personal_payment on the flight service, then book_extra_purchase
on the store service — and finishes it with the store's
Payment Flow.
| # | Call | Service | What it does |
|---|---|---|---|
| 1 | begin_personal_payment | Flight | Returns the extra purchase to pay: amount is personal_amount in VND, status is PAYING. |
| 2 | book_extra_purchase(input: { flight: { id: "<extra purchase id>" } }) | Store | Puts that extra purchase in the requester's cart as a shopping item. Only the requester can book it. |
| 3 | The rest of the Payment Flow | Store | begin_payment_checkout_session over the cart, corporate payment left off, then pay_with_wallet. |
Steps 1–2 are step 3 of the Payment Flow — "create shopping_item" — so clear the cart and cancel any open checkout session first, as that page describes.
FE calls nothing after paying. The store tells the flight service, and the request moves to
CONFIRMED on success; on failure it stays AWAITING_PERSONAL_PAYMENT, and FE starts again from
step 1.
Each attempt is its own extra purchase, carrying the request's user_request_id and
personal: true; there's at most one PAYING at a time. When the store reports a payment, it says
how it was paid, and the flight service keeps that on the extra purchase as
metadata.payment_method — wallet, corporate_wallet, bank_transfer, momo, p2c2p, vnpay
or stripe.
The UserRequest object
All amounts are in the request's currency. user_requests and admin_user_requests both return
every field below.
| Field | Type | Meaning | When it's set |
|---|---|---|---|
code | String | Readable reference, FUR + 8 digits, unique. Use it in the UI and for support. | On create. null on requests created before codes existed. |
status | UserRequestStatus | Adds AWAITING_PERSONAL_PAYMENT. | See Status flow. |
amount | Decimal | Sum of the fees' price. | When the admin issues the quote. |
fees new | [UserRequestFee] | The quoted fees. | When the admin issues the quote. Empty before that. |
corporate_amount | Decimal | What the company pays: sum of the fees' corporate_amount. | At confirm. null before that. |
personal_amount | Decimal | amount - corporate_amount: what the requester pays. Computed, never sent. | Equals amount until confirm. null when there is no amount. |
paid_personal_amount new | Decimal | How much of personal_amount the requester has paid. | 0 when issued; equals personal_amount once paid. |
extra_purchase_id removed | String | Replaced by each fee's paid and extra_purchase_ids. | Selecting it is now a query error. |
The UserRequestFee object
| Field | Type | Meaning |
|---|---|---|
id | String | Identifies the fee. Send it to confirm_user_request and add_passenger_fee. |
fee_type | FeeType | BAGGAGE, SEAT or OTHER. |
leg | FlightLeg | DEPARTURE or RETURN. |
baggage | Baggage | The baggage, when fee_type is BAGGAGE. |
seat | Seat | The seat, when fee_type is SEAT. |
other_fee | OtherFee | The charge, when fee_type is OTHER. |
currency | String | The request's currency; price and corporate_amount are in it. |
price | Decimal | The fee's full price. |
corporate_amount | Decimal | The company's share, chosen at confirm. The requester pays price - corporate_amount. null before confirm. |
paid | Boolean | true once add_passenger_fee has applied the fee and charged the company's share. |
extra_purchase_ids | [String] | The company's payment for this fee, from add_passenger_fee. |
Endpoint and authentication
Every operation in this section is a GraphQL query or mutation on the flight service's
/graphql/v1 endpoint. Field and argument names are snake_case, and enum values are upper case.
| Header | Required | Description |
|---|---|---|
Authorization | Yes | The user's session token. The flight service passes it unchanged to the store service to load the caller's profile. |
| Operations | Who can call |
|---|---|
user_requests, confirm_user_request, begin_personal_payment, decline_user_request, cancel_user_request, booking_request_progress | Any signed-in user; the four mutations only on requests the caller created |
admin_user_requests, process_user_request, add_passenger_fee | Users with the ADMIN role |
A missing or invalid token returns UNAUTHENTICATED, and the wrong role returns UNAUTHORIZED —
see Middleware Errors.
APIs
| Operation | Kind | Who | What it's for |
|---|---|---|---|
user_requests | Query | Requester | List the caller's own requests |
confirm_user_request | Mutation | Requester | Accept the quote and choose the company's share of each fee |
begin_personal_payment new | Mutation | Requester | Start paying personal_amount |
decline_user_request | Mutation | Requester | Turn the quote down |
cancel_user_request | Mutation | Requester | Withdraw a request before it is quoted |
admin_user_requests | Query | Admin | List requests, search by code, filter by claimed |
process_user_request | Mutation | Admin | Pick up, quote, resolve or reject a request |
add_passenger_fee | Mutation | Admin | Apply one fee of a resolved request to the passenger |
booking_request_progress | Query | Any signed-in user | Show who still has to approve a CONFIRMED request |
Breaking changes and rollout
This is on staging only; prod still runs the old flow. Ship the FE changes with the backend
release, not before. begin_personal_payment also needs the matching store service release.
| Change | What breaks | What to do |
|---|---|---|
process_user_request takes fees; amount removed from its input | Sending amount fails the request | Send the fees; amount is worked out from them |
confirm_user_request takes fees; corporate_amount removed from its input | Sending corporate_amount fails the request | Send each fee's corporate_amount |
New status AWAITING_PERSONAL_PAYMENT | Unknown status in labels and filters | Show it as "waiting for your payment", with a pay button |
extra_purchase_id removed from UserRequest | Selecting it fails the whole request | Use each fee's paid |
add_passenger_fee needs user_request_fee_id with user_request_id | Applying a request without it returns user_request_fee_id_required | Apply each fee with its own call |
claimed means every fee is paid | The admin list keeps a request until all its fees are applied | Nothing, if the list always sends claimed: false |
personal_pay removed from UserRequest and ProcessUserRequestInput | Selecting or sending it fails the whole request | Drop it; show corporate_amount and personal_amount instead |
AdminUserRequestsSortInput removed | A query variable declared with that type fails | Declare it as UserRequestsSortInput |
ISSUED no longer means "waiting for approvers" | Status labels read wrong | Relabel it as "waiting for your confirmation" |
They have an amount but no fees. They can still be confirmed — send no fees, and the company
pays the whole amount — but add_passenger_fee can't apply them, because it needs a fee id.
ISSUED requestsRequests already waiting for approvers before the release are stored as ISSUED. Until the backend
moves them to CONFIRMED, the FE will show them as awaiting the requester, with a confirm button
that shouldn't be there.
Still to document
- Store payment methods other than
pay_with_walletfor the personal part. The flight service records any of them, but only the wallet path is described here, and no method has been run end to end through the store yet. - The api-gateway base URL and path the FE uses to reach the flight service's
/graphql/v1. create_user_request— its input and validation rules have no page yet. Its response includescode.- Matching Bruno requests under
api-tests/backend/flight-service/. Most of these operations change data, andadd_passenger_feeand the personal payment move money, so they need a dedicated staging account and booking before they can run safely.