# Event Group Tickets, Product Group Buys — Frontend Change Log

**Author**: Josh S. Sakweli, Backend Lead Team
**Last Updated**: 2026-10-31
**Version**: v1.0
**Backend version / branch**: `fet/location_service` (+ notification server `d17b028`)
**Deployed to**: staging, already live since the 2026-10-25 deploy (`6434a415`)
**Compared with**: staging as deployed on 2026-10-06 (`448f06a0`), before group tickets existed

**Base URL**: `https://api.nexgate.co/api/v1` (staging: `https://dev.api.nexgate.co/api/v1`)

**Short Description**: Event tickets can now be bought **together at a group price** ("3 tickets at 15,000 instead of
20,000"). Ticket responses and the event checkout gained fields for it. Product group buys changed too: moving and leaving
a group now take a **whole payment**, there is a new **Leave group** action, and filled groups now really create their
orders (before, they never did).

**Audience**: mobile team · web team

**Related docs** — the full flows, with every new endpoint, live in their own docs (not repeated here):
- `event_group_tickets_api_doc.md` — event tickets "buy together"
- `product_group_purchase_api_doc.md` — product group buys
- `docs/changelog/frontend_changelog_2026-10-25.md` §7.1 — group checkout with pickup / delivery

---

## How to read this

| Badge | Means | App must act? |
|---|---|---|
| <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> | a new endpoint, field or notification | only to use the new feature |
| <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> | an existing endpoint gained fields or behaves differently; old calls still work | should update |
| <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> | a bug fixed; same shape, now correct values | check screens that relied on the wrong value |
| <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> | an old call can now be refused or means something different | **must update** |

One change is breaking: moving a product group payment (§2.2). Everything else keeps working.

---

## 0. At a glance

| # | Service | New | Changed | Breaking | App work |
|---|---|---|---|---|---|
| 1 | Event tickets | 7 | 3 | — | "Buy together" for organisers and buyers; waiting state with no QR until the group is full |
| 2 | Product group buys | 1 | 1 | 1 | pick a payment, Move sends `checkoutSessionId`, new Leave button |
| 3 | Notifications | 4 | — | — | 4 new types, 1 new target |

---

# 1. Event tickets

**What changed in this service**: an organiser can switch on group buying for a **paid** ticket type. One buyer starts a
group, friends join with a code, and when the group is full everyone gets their tickets at once. If it does not fill in
time, everyone is refunded automatically.

**Example:** VIP is 20,000. The organiser sets group price 15,000, size 3.
- Asha starts a group "Yoyo Crew" with 2 tickets → pays 30,000 → **no booking yet**, she sees "2 of 3".
- Baraka joins with the code with 1 ticket → pays 15,000 → group is full → Asha gets 2 tickets, Baraka 1, with QR codes.
- ❌ If nobody joins before the deadline → Asha gets her 30,000 back.

Rules the app must show:
- Size counts **tickets, not people**.
- **No ticket and no QR code until the group is full.**
- Paid tickets only (not free, donation or door sales).
- A group closes at the earliest of: its time limit, the end of ticket sales, or **2 hours before the event**.
  `expiresAt` is the deadline.
- Each friend buys with their own account: a group checkout cannot buy for `otherAttendees`.

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| NEW | PATCH | `/e-events/tickets/{ticketId}/group-buying` | organiser switches group buying on / off | to use |
| CHANGED | GET | ticket details, ticket list, event `tickets` | `groupBuying` object | should update |
| CHANGED | POST | `/e-events/checkout` | optional `group` in, `group` out | to use |
| CHANGED | POST | `/e-events/checkout/{sessionId}/payment` | can fill a group; new "just filled" error | should update |
| CHANGED | PATCH | `/e-events/booking-orders/{bookingId}/cancel` | also works for bookings from a group | no change |
| NEW | GET | `/e-events/ticket-groups/ticket/{ticketTypeId}/open` | open groups for a ticket | to use |
| NEW | GET | `/e-events/ticket-groups/code/{groupCode}` | look up a shared code (`TG-` + 6) | to use |
| NEW | GET | `/e-events/ticket-groups/{groupId}` | one group | to use |
| NEW | GET | `/e-events/ticket-groups/my` | my groups | to use |
| NEW | POST | `/e-events/ticket-groups/{groupId}/transfer` | move my tickets to another group | to use |
| NEW | POST | `/e-events/ticket-groups/{groupId}/withdraw` | leave with my tickets, full refund | to use |

## 1.1 <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> Organiser: switch group buying on

**Endpoint**: <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/e-events/tickets/{ticketId}/group-buying`

```json
{ "enabled": true, "groupPrice": 15000, "groupSize": 3, "timeLimitHours": 24 }
```

| Field | Rule |
|---|---|
| `enabled` | required; `false` switches it off, groups already running still finish |
| `groupPrice` | required to switch on; above 0 and **below** the ticket price |
| `groupSize` | required to switch on; 2 up to the ticket's total quantity |
| `timeLimitHours` | required to switch on; 1 to 720 |

Changing price or size later does not touch groups already running. Errors are `400`, show the message as it is
(for example "Group price must be lower than the ticket price (20000.00)").

## 1.2 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Ticket responses — `groupBuying`

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-events/tickets/{eventId}/{ticketId}` · the event's ticket list · the PATCH in §1.1

| Field | Before | After |
|---|---|---|
| `groupBuying` | — | **added**; missing when group buying is off and no group is running |

```json
"groupBuying": { "enabled": true, "groupPrice": 15000.0, "groupSize": 3, "timeLimitHours": 24,
                 "availableToBuy": 14, "heldInOpenGroups": 3, "paidInOpenGroups": 2, "openGroups": 1, "sold": 0 }
```

| Field | Meaning |
|---|---|
| `availableToBuy` | tickets anyone can still buy now |
| `heldInOpenGroups` | kept back for groups still filling (a whole group's worth from the moment it starts) |
| `paidInOpenGroups` | how many of those are paid |
| `openGroups` | groups running now |
| `sold` | real bookings, including from filled groups |

### App to do
- [ ] Organiser ticket screen: "Buy together" switch with price / size / time, and the counts above.
- [ ] Ticket page: show "Buy together at 15,000" when `groupBuying.enabled`.

## 1.3 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Event checkout — `group`

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-events/checkout`

### Request
| Field | Before | After |
|---|---|---|
| `group` | — | **added**, optional: `{ "groupName": "Yoyo Crew" }` starts a group, `{ "groupId": "…" }` joins one |

```json
{ "eventId": "1635…", "ticketTypeId": "8a31…", "ticketsForMe": 1, "group": { "groupId": "3a6d…" } }
```

### Response
| Field | Before | After |
|---|---|---|
| price / totals | ticket price | the **group price** when `group` was sent |
| `group` | — | **added**: `{ "groupName": "Yoyo Crew", "startsNewGroup": true }` or `{ "groupId": "…", "startsNewGroup": false }`; missing on a normal checkout |

### Errors (`400`, show as is)
- `Group name is required when starting a group`
- `An open group named 'yoyo crew' already exists for this ticket` (names are unique per ticket, case ignored)
- `Only 1 tickets left in this group` · `This group is no longer open` · `This group is for a different ticket`
- `Group buying is not available for this ticket` · `It is too close to the event to start a group`
- `Group tickets are bought for yourself; each friend joins the group with their own account`
- `You can hold at most 6 of this ticket; 2 are already waiting in groups` — the per-person limit counts waiting tickets

## 1.4 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Pay — may fill the group

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-events/checkout/{sessionId}/payment` — same request

| After paying a group checkout | What the user has |
|---|---|
| started a group | group `OPEN`, **no booking** |
| joined, group not full | in the group, **no booking** |
| this payment filled the group | **every member** gets bookings and QR codes at once, in My bookings |

New error to handle: `This group just filled up or closed. You have not been charged.` — two people paid for the last
place at the same moment and the other got it. Nothing was taken; send the user back to the group list.

## 1.5 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Cancel a booking

**Endpoint**: <span style="background-color: #17a2b8; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/e-events/booking-orders/{bookingId}/cancel` — unchanged

Also works for a booking that came from a group: it refunds exactly those tickets and puts them back on sale.

## 1.6 <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> Ticket groups — read, move, leave

Full shapes and errors: `event_group_tickets_api_doc.md` Parts 2 and 4. The parts the screens need:

| Field on a group | Meaning |
|---|---|
| `status` | `OPEN`, `COMPLETED` (full, tickets issued), `FAILED` (ended, refunded) |
| `seatsOccupied` / `totalSeats` | the "2 of 3" |
| `endReason` | when ended: `FILLED`, `EXPIRED`, `EMPTIED`, `EVENT_CANCELLED`, `TICKET_DELETED` |
| `myTickets` | the viewer's tickets in it; missing when none |
| `participants` | who is in, with how many `tickets` each — no money shown; `isMe: true` on the viewer; empty once `FAILED` |

- **Move** `{ "targetGroupId": "…", "quantity": 1 }` — any number of my tickets, same event and same ticket type, no
  money moves. If it fills the target, everyone there gets tickets at once.
- **Leave** `{ "quantity": 1 }` — any number of my tickets, refunded in full at once; the places open up again.

### App to do
- [ ] Ticket page: open groups list, "Join with a code", "Start a group".
- [ ] My groups: "Waiting for group: 2 of 3" with `expiresAt` — **never a ticket or QR** while `OPEN`.
- [ ] Group screen: participants (highlight `isMe`), Move and Leave.

---

# 2. Product group buys

**What changed in this service**: moving and leaving now take **one whole payment** — its items, delivery and tax stay
together. Each payment a user makes in a group becomes **its own order**.

**Example:** Asha pays twice in a group: payment A (1 item, 900) and payment B (1 item, 900).
- ✅ Move payment A to another group → A's item and money go there, B stays.
- ✅ Leave with payment B → 900 (plus its delivery) back at once.
- ❌ Before: "move 1 item" did not say which payment, so the wrong money could be refunded, and a full move left Asha with
  no order.

| Change | Method | Endpoint | One line | App |
|---|---|---|---|---|
| CHANGED | GET | `/group-purchases/{groupId}`, `/code/{groupCode}`, `/my-participations` | `purchaseHistory` = the payments to pick from | should update |
| BREAKING | POST | `/group-purchases/transfer` | `checkoutSessionId` instead of `quantity` | must update |
| NEW | POST | `/group-purchases/{groupId}/withdraw` | leave with one payment, full refund | to use |
| FIX | — | group fills / expires | orders created, every payment refunded | check screens |

## 2.1 <span style="background-color: #fd7e14; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">CHANGED</span> Group reads — `purchaseHistory`

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/e-commerce/group-purchases/{groupId}` · `/code/{groupCode}` · `/my-participations`

| Field | Before | After |
|---|---|---|
| `purchaseHistory` | only the first payment for some members | **every payment** (one entry = one payment); only on the **viewer's own entry** — missing on others and when logged out |
| `purchaseCount` | | how many payments |

```json
"purchaseHistory": [
  { "checkoutSessionId": "dd9b…", "quantity": 1, "amountPaid": 900.00, "purchasedAt": "2026-10-07T12:40:11" },
  { "checkoutSessionId": "28bc…", "quantity": 1, "amountPaid": 900.00, "purchasedAt": "2026-10-07T12:41:03" }
]
```

Missing `purchaseHistory` means "not mine", never an error.

## 2.2 <span style="background-color: #dc3545; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">BREAKING</span> Move a payment to another group

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/group-purchases/transfer`

### Request
| Field | Before | After |
|---|---|---|
| `quantity` | how many items to move | **ignored** (an old body is not refused) |
| `checkoutSessionId` | — | **added**: which payment moves; may be left out when the user paid only once |

<table>
<tr><th>Before</th><th>After</th></tr>
<tr><td>

```json
{ "sourceGroupId": "34e5…",
  "targetGroupId": "5604…",
  "quantity": 1 }
```

</td><td>

```json
{ "sourceGroupId": "34e5…",
  "targetGroupId": "5604…",
  "checkoutSessionId": "dd9b…" }
```

</td></tr>
</table>

Why it is breaking: an old app that sends `quantity: 1` for a payment of 2 items now moves **both**, and a user who paid
twice is refused until the app sends `checkoutSessionId`. Only between groups of the same product and shop at the same
group price. To move only part of a payment: leave with it (§2.3) and buy again in the other group.

### Errors (`400`)
- `You paid 2 times in this group; choose which payment with checkoutSessionId`
- `That payment is not in this group` · `The target group does not have 3 free seats`
- `Payments can only move out of a group that is still open` · `Source and target groups must be different`

## 2.3 <span style="background-color: #6f42c1; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">NEW</span> Leave a group — full refund

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/e-commerce/group-purchases/{groupId}/withdraw`

```json
{ "checkoutSessionId": "28bc…" }
```

Body may be empty when the user paid once. Only while the group is `OPEN`. The whole payment (items, delivery, tax) goes
back to the wallet at once. The response is the user's entry: `status: REFUNDED` when nothing is left, still `ACTIVE`
when another payment remains. A user who left can join again.

Errors (`400`): `You can only leave a group that is still open`, `You have no active payment in this group`.

## 2.4 <span style="background-color: #20c997; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: bold;">FIX</span> Filling, expiring, racing

| What | Before | After |
|---|---|---|
| a group fills | **no orders were created** — buyers paid and got nothing | one order per payment, within seconds; a job every 15 min creates any that were missed |
| a buyer paid twice | one order, one refund | an order / refund for each payment |
| a group expires | refunds failed; checked only at midnight | every payment refunded; checked every 15 min |
| two people pay for the last place | both could get in | one gets it; the other sees `Checkout failed during post-payment processing. Please contact support.` and **is not charged** |
| everyone moved out | stock stayed held | stock back on sale, group `DELETED` |
| "You saved" on the order | today's product price | the price the group started with |

### App to do
- [ ] My groups: list the payments from `purchaseHistory` ("1 item · 900"); skip the choice when there is one.
- [ ] Move: send the chosen `checkoutSessionId`, stop sending `quantity`.
- [ ] New **Leave group** button while `OPEN`.
- [ ] The last-place error: send the user back to the group list.

---

# 3. Notifications

All four open the group: `targetType: "EVENT_TICKET_GROUP"`, `targetId` = group id →
`GET /e-events/ticket-groups/{targetId}`. An app that does not know the target yet opens the bell list.

| Change | Type | To | When | App |
|---|---|---|---|---|
| NEW | `EVENT_TICKET_GROUP_CREATED` | the buyer who started it | group opened — prompt to share the code | |
| NEW | `EVENT_TICKET_GROUP_JOINED` | everyone in the group | someone joined or moved tickets in | refresh "2 of 3" |
| NEW | `EVENT_TICKET_GROUP_COMPLETED` | everyone in the group | full — tickets are in My bookings | refresh bookings |
| NEW | `EVENT_TICKET_GROUP_FAILED` | everyone still in it | ended without filling — refunded | |

A refund also sends the existing `ESCROW_REFUNDED`, once per person (not once per ticket).

---

# 4. App checklist by screen

| Screen | What to change | Sections |
|---|---|---|
| Ticket settings (organiser) | "Buy together" switch, price / size / time, `groupBuying` counts | §1.1, §1.2 |
| Ticket page / event checkout | buy-together price, open groups, join by code, send `group`; "just filled" error | §1.2–§1.4 |
| My ticket groups | waiting "2 of 3" with no QR; participants; Move / Leave | §1.6 |
| Product group — my groups | pick a payment; Move with `checkoutSessionId`; Leave button | §2.1–§2.3 |
| Bell list / push | 4 new types, target `EVENT_TICKET_GROUP` | §3 |