# Premium: scope and rules

Status of 8 October 2026 · on branch `vue3-redesign`, **not on `master`** · maintained by Mitko (with Claude Code) · readers: development team and product owner

This document says what Premium is today, what it unlocks, how a lock behaves in each frontend and on the server, and what is still open. It describes what the code does, not what we would like. The checklist rows are in [`V3-PARITY-PLAN.md`](../V3-PARITY-PLAN.md), section 5 I; the open security items in [`SECURITY-BACKLOG.md`](../SECURITY-BACKLOG.md), SEC-014; the tests in [`TESTING-BACKLOG.md`](../TESTING-BACKLOG.md).

---

## 1 · Summary for the product owner

**Who is Premium.** An account with the premium role or the admin role. It is granted when a subscription is paid (price 36 euro a year, from the Mollie flow, see `MOLLIE.md`) and taken away a few days after it ends (`DOWNGRADE_AFTER_DAYS`, default 3).

**What a free account cannot do:**

| Function | Free | Premium |
|---|---|---|
| Hidden mode (answers visible only to the admin) | cannot switch it on; a poll that already has it keeps it and it can be switched off | yes |
| Excel export | no | yes |
| Invitations sent through kulibri | up to 50 people, the poll's participants counted in | no limit |
| Messages to participants | no | yes |
| Picking people from your other projects when inviting | no | yes |
| Own background images | 3 | up to 20 (server) |
| An own logo | no | yes |
| Saving a custom design, "Save as", the non-standard design templates | no | yes |
| Automatic reminder mails | no | yes |
| Tasks ("who brings what") | 10 | no limit |
| Making a participant a sub-admin | no | yes |

**What does not change:** sharing the link, answering, comments, the standard designs, copying a poll, the QR code. Participants never need an account and never pay.

**Four of these are new on 8 October** and were free before: hidden mode, the Excel export, invitations between 51 and 100, and messages to participants. Free users lose them, so they need to be told (see section 7).

---

## 2 · The rule: who is Premium

One rule, in three places that must agree:

| Where | What | File |
|---|---|---|
| Server | `User::isPremium()`: the premium bit **or** the admin bit of `users.roles`, also next to other bits (beta, alpha) | `app/Models/User.php` |
| New UI | `isPremiumFor(ownerRoles, viewerRoles)` and `hasPremiumRole` | `frontend-v3/src/utils/premium.ts`, `utils/roles.ts` |
| Classic | the Vuex getter `isPremium` | `frontend/src/store/store.js` |

The role bits: premium `1`, beta `2`, alpha `4`, admin `256`. So `0`, `2` and `4` are free; `1`, `3`, `256` and `257` are Premium.

**The poll creator decides**, not the person looking. On a poll, whoever acts as its admin (by login or by admin link, also without an account) uses the creator's Premium. The visitor's own roles are only a fallback when the answer carries no roles of the creator. The server works the same way: its routes take a poll id and need no login, so they check the **owner** of the poll.

Before 8 October the three places disagreed: the new UI's editor used `auth.user.plan`, and the server compared `roles != 1`, which counted an admin or a premium+beta account as free.

---

## 3 · Scope in detail

"Origin": **classic** means classic already locked it and the new UI follows; **new rule** means decided on 8 October, not in classic before.

| Feature | Origin | Classic | New UI | Server |
|---|---|---|---|---|
| Hidden mode | new rule | toggle refuses, premium dialog (`EditPollOptions.vue`, `EditKulibri.vue`) | toggle refuses, notice (`PollEditorView.vue`) | `postDetails`, `postOptions`, `postNew` answer 402 |
| Excel export | new rule | `Headline.vue` `onExport`, premium dialog | `exportExcel()` and both buttons, notice | `POST /download-excel/{id}` answers 402. The new UI builds the sheet in the browser, so its lock is advisory |
| Invitations over the limit | new rule (classic: 100) | `EditInvites.vue`, limit message; `constants.freeInviteLimit` | both invite panels, send button disabled | `POST /invitation` answers 402 above `app.free_invite_limit` |
| Messages to participants | new rule | `Headline.vue` `onSendReminder`, premium dialog | the panel opens and shows the notice, send disabled | `POST /reminder-to-participant` answers 402 |
| Pick people from other projects | classic | premium dialog (`EditInvites.vue`) | notice (`PollDetailView.vue`) | not checked; only reads `GET /ownpolls` |
| Own background images | classic | upload hidden above 3, custom image needs Premium | same | `UserController::imageUpload`: 3, or 20 with a paid first subscription |
| An own logo | classic | premium dialog (`EditLogo.vue`) | `pickLogo`, notice | **not checked** |
| Custom design: save, "Save as" | classic | `CustomBranding.vue` | `saveDesign`, `openSaveAs` | **not checked** |
| Design templates | classic | non-standard ones disabled (`SelectField.vue`) | non-standard ones disabled in the dropdown | **not checked** |
| Automatic reminder mails | classic | check boxes disabled | check boxes disabled, notice | not checked |
| Tasks | classic | the 11th is refused | `TasksTab.vue`, limit dialog | not checked |
| Sub-admin | classic | toggle disabled (`ModalParticipant.vue`) | toggle disabled (`ParticipantEditDialog.vue`) | not checked; flags are cleared on downgrade (probably not working, SEC-014) |

**Markers with no check** (the badge only, in classic and in the new UI): seats per slot ("Je Termin"), allow tasks, booking mode, the sub-admin section header, and "lock answers after sending" (new in the new UI). They say Premium and do nothing. The PO decides (section 7).

**Free limits and where they are set**

| Limit | Value | Set in |
|---|---|---|
| Invitations through kulibri | 50, the participants already in the poll counted in | server `config/app.php` `free_invite_limit` (`FREE_INVITE_LIMIT`); new UI `FREE_INVITE_LIMIT` in `utils/premium.ts`; classic `constants.freeInviteLimit` |
| Own images, free | 3 | server `allow_images` (`ALLOW_IMAGE`); new UI `FREE_CUSTOM_IMAGES` in `data/standardImages.ts`; classic `constants.freeImages` |
| Own images, Premium | 20 | server `allow_images_for_premium` |
| Tasks | 10 | new UI `FREE_TASK_LIMIT` in `utils/tasks.ts`; classic `nmbFreeTasks` (hard-coded) |

The three numbers for invitations must stay equal. If you change one, change all three.

---

## 4 · How a lock behaves

**Server.** A refused action answers **402** with this body, so a client can tell it from a missing login (401) and from a plain refusal (403):

```json
{ "error": { "message": "premium_required", "feature": "hiddenMode" } }
```

`feature` is one of `hiddenMode`, `excelExport`, `inviteLimit` (with `limit` and `total`) or `reminder`. A refused invitation sends nothing and creates nothing. The old mail "SPAM Alert" to support for a free user over the limit is gone.

**New UI.** A locked control stays visible. Clicking it shows `PremiumNotice` in place: "what is locked: nur mit Premium" and a link to the membership page. Texts: `src/i18n/premium.ts`, German and English. If the server refuses anyway (the data was out of date), `premiumRefusalMessage` shows the same sentence.

**Classic.** The existing premium dialog (`ModalPremiumFeatureDialog`), opened by `openPremiumDialog` from the mixin `PremiumDialogMixin`. A 402 is shown as the dialog or the limit message and no longer as "sent" or a spinner (`isPremiumRefusal` in `shared/premium.js`).

**Hidden mode keeps existing polls.** Classic never charged for it, and the participants of such polls rely on it. A free owner keeps hidden mode on a poll that has it and can switch it off; once that is saved they cannot switch it on again. The server applies the same rule by comparing the saved options with the new ones (`PollController::turnsHiddenModeOn`). A copy of a hidden poll counts as a new poll: a free owner who copies one gets a refusal on the first save, the switch goes back to off, and the premium dialog says why. In the new UI the copy fails in its dialog with the same notice ("Der verdeckte Modus: nur mit Premium."), because `/new` refuses the flag (`usePollCopy`).

---

## 5 · Enforcement on the server today

Enforced (tests in `tests/Feature/PremiumEnforcementTest.php`, 19 tests): the four routes above, and `POST /new` for hidden mode. Image upload is limited by the first subscription, not by the role.

**Not enforced**, so a client that calls the API directly can use them without Premium: own logos (`POST /logo/upload`, `POST /poll/logo/{id}`), custom designs (the `/designs` resource), automatic reminders, tasks, sub-admin flags. These stay open items in SEC-014.

All the routes above work with a poll id and need **no login**, so the server checks the owner's Premium, not the caller's. Anyone holding a poll id can still call them (SEC-013, G16).

---

## 6 · Where the code is

| What | Where |
|---|---|
| The server rule and the checks | `app/Models/User.php` (`isPremium`), `app/Http/Controllers/PollController.php` (`premiumRequired`, `turnsHiddenModeOn`, `postInvitations`, `exportPollDetails`, `sendRemindarToParticipants`), `config/app.php` |
| New UI: rules, notice, texts | `frontend-v3/src/utils/premium.ts`, `utils/roles.ts`, `components/PremiumNotice.vue`, `i18n/premium.ts` |
| New UI: the gates | `views/PollEditorView.vue`, `views/PollDetailView.vue`, `views/InvitePanel.vue`, `components/ParticipantReminderPanel.vue`, `components/tasks/TasksTab.vue` |
| Classic: rules and dialog | `frontend/src/shared/premium.js`, `components/mixins/PremiumDialogMixin.js`, `components/~Global/Dialogs/ModalPremiumFeatureDialog.vue` |
| Classic: the gates | `components/Rendering/Headline.vue`, `components/Edit/EditPollOptions.vue`, `EditKulibri.vue`, `EditPoll.vue`, `EditInvites.vue`, `SendParticipantReminder.vue` |
| Tests | `tests/Feature/PremiumEnforcementTest.php`, `frontend-v3/src/__tests__/premium.spec.ts`, `premiumNotice.spec.ts`, `frontend/tests/unit/premium.spec.js` |

---

## 7 · Open points and risks

**For the product owner** (plan, section 7, questions 6, 10 and 11):
1. The free invitation limit is **50**, a proposal. Classic allowed 100, and the product rules say functions that bring in new participants are never priced. Keep 50, go back to 100, or make every kulibri-sent invitation Premium?
2. Free users **lose** the Excel export, messages to participants, and invitations between 51 and 100. Announce it, and from when?
3. Existing hidden polls of free owners keep working. Confirm.
4. The markers without a check (section 3): remove, keep, or make real?
5. Classic offers people from other projects only when their address is **already in your contacts**, and leaves the others out silently. Keep that filter?
6. "Sammelexport eigener Felder" (a collective export of own fields) does not exist in either UI. If it is wanted, it is new work.

**For the team:**
- **Ship the server change and the classic gates together.** Deploying the server alone gives classic users refusals. They are shown now, but they are still refusals.
- **The new UI's Excel lock is advisory**: the sheet is built in the browser from data the visitor already has.
- **Nothing here has been checked in a browser yet.** The matrix is in section 8.
- The grant and revoke rules for the premium bit differ in three places in the Mollie code (SEC-014; `MOLLIE.md`).

---

## 8 · How to check it

For each of the four new rules and for the classic gates, use these accounts: free (`roles` 0), premium (1), admin (256), premium+beta (3), and an **admin-link visitor without a session** (classic reads the poll creator's roles there).

| Check | Expect |
|---|---|
| Free owner switches hidden mode on | refused with the notice (new UI) or the dialog (classic); the switch stays off |
| Free owner opens a poll that has hidden mode | it is on and stays on after saving; switching it off works; switching it on again is refused after that |
| Free owner copies a hidden poll | the first save is refused, the switch goes back to off, the dialog explains |
| Free owner presses Excel / messages | notice or dialog, nothing is downloaded or sent |
| Free owner invites more than 50 (participants counted in) | send disabled with the limit text; calling the API gives 402 and nothing is sent |
| Premium, admin, premium+beta | all of the above work |
| `curl -X POST /api/download-excel/{id}` for a poll of a free owner | 402 |

Automated: `php artisan test --filter=PremiumEnforcementTest`; in `frontend-v3`: `npx vitest run src/__tests__/premium.spec.ts`; in `frontend`: `npx vue-cli-service test:unit`.

---

## 9 · Changing a rule

A Premium rule lives in three places and a lock in up to three more. When you add or move one:
1. Change the rule in `User::isPremium()`, `utils/premium.ts` and the classic getter together, and the tests of all three.
2. Add the gate in the new UI with `PremiumNotice` and a text in `i18n/premium.ts` (both languages), in classic with `openPremiumDialog`, and on the server with `premiumRequired(feature)` for a route.
3. Make the server answer **402** with the body of section 4, and make both frontends show it.
4. Add a row to section 3 of this document, update `V3-PARITY-PLAN.md` (section 5 I) and `TESTING-BACKLOG.md` in the same change.
