Discount codes in NeetoForm

# PRD: Discount Codes for NeetoForm

- **Date:** 2026-06-01
- **Author:** Alan Mathews
- **Status:** Draft — pending engineering review
- **Reference implementation:** NeetoCal (`neeto-cal-web` / `neeto-cal-api`)

---

## 1. Summary

NeetoForm can already collect payments on a form via Stripe, Razorpay, and Manual
UPI, and can sell multiple items through the Product List component. Customers now
want to offer **discount / promo / referral codes** so a respondent can reduce the
amount they pay.

NeetoCal already ships a mature discount-code feature. This PRD specifies porting
that feature to NeetoForm **as a near-verbatim reuse of the NeetoCal implementation**:
discount codes are created and managed **globally at the organization level** (with the
list, redeems, and reports views), then **enabled for specific forms**. The form's
payment element shows which codes apply and links out to the global settings to manage
them — exactly as NeetoCal surfaces enabled codes from a scheduling link. The only
structural change from NeetoCal is that **forms play the role NeetoCal's meetings
play**, and three email-identity-dependent features are dropped (§4) because a form
submission has no guaranteed respondent identity.


### Internal context (from the feature walkthrough video)

- Now that the Product List component makes collecting payment more central, discount
  codes have become more important.
- NeetoCal already has discount codes, so the logic and UI should be reused as-is.

The video initially suggested keeping codes purely form-level (on the assumption that
NeetoForm has no real admin/organization surface, since payment is connected per form).
On review that assumption proved inaccurate — see §5 — so this PRD adopts the global,
enabled-per-form model, which is both the closer NeetoCal port and the better fit for
reuse across forms.

## 5. Architecture fit & scope mapping vs NeetoCal

The video's premise — that NeetoForm lacks an admin/organization surface and therefore
codes must be per-form — does not hold. NeetoForm already has:

- An **organization-level admin panel** (`/admin/admin-panel/...`) hosting global
  settings today: IP restrictions, roles, audit logs, custom domains, API keys, and
  **org-level webhooks**.
- A clear ownership hierarchy: `Form belongs_to :organization`
  (`app/models/form.rb`); `Organization has_many :forms`.
- An **existing "global resource, used/enabled per form" precedent**: payment accounts
  are partly org-owned (`Organization has_many :razorpay_integrations`,
  `has_one :stripe_platform_account`, …) and attached to forms via a polymorphic
  `holdable` relationship; webhooks exist both org-level and form-level.

So the global, enabled-per-form model is a natural fit. The mapping is a near-direct
port of NeetoCal with one substitution — **meeting → form**:

| Dimension | NeetoCal | NeetoForm (this PRD) |
|---|---|---|
| Ownership | Organization (`belongs_to :organization`) | **Organization** (same) |
| Applicability | Enabled per **meeting** via HABTM (`discount_codes_meetings`) | Enabled per **form** via a join table (`discount_codes_forms`) |
| Uniqueness of `code` | Unique per organization | Unique per organization (same) |
| Global management UI | Admin Settings panel (`Settings/DiscountCodes`) incl. redeems + reports | Org admin panel (`/admin/admin-panel/...`) incl. redeems + reports |
| Per-target enablement UI | From a meeting, choose enabled codes | **From the form's payment element**, choose enabled codes + link to global settings |
| Redeem record key | `booking_id` | `submission_id` |
| Currency of fixed codes | Org currency | Code carries a currency; enableable only on currency-matching forms (§6.5) |

## 6. Functional requirements

### 6.1 Creating / managing codes (admin, global)

- Created and managed in the **organization admin panel**, not inside a single form,
  including the list/table, the redeems history, and the reports views.
- Code: 3–30 characters, no whitespace, auto-uppercased, unique within the
  organization.
- Type: percentage or fixed amount.
  - Percentage: value 1–100. Currency-agnostic.
  - Fixed: value > 0, denominated in a **currency chosen on the code** (§6.5).
- Status: enabled / disabled. A disabled code fails public validation everywhere but is
  retained.
- Expiry (`expires_at`): optional. A code is invalid on or after its expiry date.
- Usage limit (`usage_limit`, NeetoCal's `redeems_limit`): optional total cap on
  redemptions across all forms and respondents. When the redeem count reaches the limit,
  the code stops validating.

### 6.2 Enabling codes for forms (admin) — under the payment element

- Enablement is **per form** (join: code ↔ form), surfaced **inside the form builder's
  payment element configuration**. When a payment element is selected and placed, the
  payment fields are shown, and **below those fields** there is an **"Applicable
  discount codes"** section. This applies to each payment element (Stripe, Razorpay,
  Manual UPI, and the Product List config). The section:
  1. Lists **all** available organization discount codes as a **checklist** (one
     checkbox per code).
  2. Lets the admin **"select all"** or **uncheck individual codes**.
  3. **Pre-checks** the codes already selected for this form at the global level and
     keeps them selected for that form; toggling a checkbox enables/disables that code
     for the form.
  4. For a **newly created form**, none of the global discount codes are checked by
     default — they all start **unchecked**.
  5. Stays **in sync with the global level**: whatever is checked at the form level
     inside the payment element matches what is selected for that form at the global
     level (the form-level checklist and the global "Applied forms" selection are the
     same code ↔ form join, kept consistent both ways).
- Because the join is per form, the set is shared across a form's payment elements; each
  element's section reflects and edits the same form-level set.
- A **fixed-amount** code can only be enabled for a form whose payment currency matches
  the code's currency (§6.5). Percentage codes can be enabled for any paid form.

### 6.3 Applying a code (respondent, public page)

- A discount-code input appears in the payment area of the public form (and adjacent to
  the Product List order summary), styled to match **NeetoForm's** payment UI rather
  than NeetoCal's.
- On "Apply", the client calls the public verify endpoint (§7.3). On success the payable
  amount recomputes and an "applied" state is shown (code name + discounted amount +,
  for percentage codes, the percentage). The respondent can remove the applied code to
  revert to the original amount.
- On failure, a clear inline error is shown for: not found, not enabled for this form,
  disabled, expired, or usage-limit reached.

### 6.4 Discount application & ordering

- The discount reduces the **final payable amount in-app, before the payment provider
  is charged** (matching NeetoCal — no provider-side coupon objects).
- Base amount the discount applies to, by path:
  - **Stripe / Razorpay / Manual UPI** (fixed or range amount): the entity's amount.
  - **Product List**: the computed cart `total`.
- **Order of operations:** discount first, then tax on the discounted amount — matching
  NeetoCal's behavior. *(Decision to confirm with the tax-logic owner during
  implementation planning.)*
- The discounted payable amount is clamped at a floor of 0 (a fixed discount larger than
  the amount results in a 0 charge, never negative).

### 6.5 Currency

- **Percentage** codes are currency-agnostic and may be enabled on any paid form.
- **Fixed-amount** codes carry a currency. A fixed code can only be enabled for a form
  whose configured payment currency matches the code's currency. This keeps a
  "$10 off" code from being applied to a form that charges in EUR.
- The global create/edit UI surfaces the currency selector for fixed codes; the
  per-form enablement section filters/guards on currency match.

### 6.6 Redemption tracking (ported from NeetoCal)

- On a successful payment/submission where a valid code was applied, create a
  **redeem record** (`discount_codes_redeems`, keyed on `submission_id`) capturing the
  original amount, paid amount, and currency — a direct port of NeetoCal's
  `DiscountCodes::Redeem`.
- "Times redeemed" is the count of redeem records; the usage limit (§6.1) is enforced
  against it. (An optional cached counter is an implementation detail.)
- The redeems and reports views in the global admin settings read from this table.


## 8. Frontend design (neeto-form-web)

### 8.1 Global management UI (org admin panel)

- A new **"Discount codes"** section in the organization admin panel
  (`/admin/admin-panel/...`), registered in `app/javascript/src/routes.js` and the admin
  panel constants (`components/AdminPanel/`), following the existing admin-panel section
  pattern (roles, webhooks, etc.).
- Ports NeetoCal's `Settings/DiscountCodes` components near-verbatim:
  - List/table + create/edit panes (`index.jsx`, `Pane.jsx`,
    `Form/GeneralSettings.jsx`, `constants.js`), with an "Applied forms" tab (port of
    `Form/MeetingLinksSettings.jsx`) to choose which forms a code is enabled for.
  - The **Show view** with the **Redeems** table (`Show/Redeems/`) and **Reports**
    (`Show/Reports/`) — the "redeems and other details" surface.
  - **Omitting** only the allowed-emails / per-email-limit / validity-window fields.
- React Query hooks mirror NeetoCal's `useDiscountCodesApi.js` (including redeems/report
  hooks).

### 8.2 Per-form enablement UI — inside the payment element

- No separate form-settings card. Instead, the **payment element configuration panels**
  in the builder gain an "Applicable discount codes" section, placed **below the payment
  fields**:
  - `Builder/.../QuestionTypes/Payments/StripePayment.jsx`
  - `Builder/.../QuestionTypes/Payments/RazorpayPayment.jsx`
  - `Builder/.../QuestionTypes/Payments/ManualUpiPayment.jsx`
  - `Builder/.../QuestionTypes/ProductList/index.jsx`
- The section renders **all** the org's codes as a **checklist with "select all" and
  individual uncheck**, pre-checks codes already selected for the form at the global
  level (new forms start with everything unchecked), toggles enablement via the per-form
  endpoint (§7.3) and stays in sync with the global selection both ways, filters/guards
  fixed codes by currency match, and shows an **"Add & manage discount codes"** link to
  the global admin settings. (Functional analog of NeetoCal's per-meeting enablement
  UI.)

### 8.3 Public form-fill UI

- A discount-code input + applied-state card, ported from NeetoCal's
  `Live/common/DiscountCodeForm.jsx` and `Live/Booking/Payment/DiscountSection.jsx` but
  **restyled to match NeetoForm's payment UI**, placed in
  `app/javascript/src/components/Form/commons/Elements/Payments/` and near the Product
  List order summary.
- On apply, call the verify endpoint; recompute the displayed total by feeding the
  discount into the existing pricing flows (`useParsePaymentAmount` for fixed/range
  provider amounts, `useProductListPricing` for the Product List total).
- Reuse the `neetopayments` amount-math helpers
  (`getFormattedDiscountedAmount`, `getFormattedDiscountedPercentage`) — the same
  package NeetoCal uses — for consistent formatting.

## 9. NeetoCal → NeetoForm reference map

For engineers porting the feature, the source files to study:

**Backend (neeto-cal-api):**
- Model: `app/models/discount_code.rb`; redeem: `app/models/discount_codes/redeem.rb`
- Verify service: `app/services/bookings/verify_discount_code_service.rb`
- Redeem on confirm: `app/services/concerns/bookings/create_concern.rb`
- Global admin controller: `app/controllers/api/v1/settings/discount_codes_controller.rb`
- Redeems controller: `app/controllers/api/v1/settings/discount_codes/redeems_controller.rb`
- Per-meeting enablement: `app/controllers/api/v1/meetings/discount_codes_controller.rb`
- Public verify: `app/controllers/api/v1/public/bookings_controller.rb`

**Frontend (neeto-cal-web):**
- Global list/form: `app/javascript/src/components/Settings/DiscountCodes/`
  (`index.jsx`, `Pane.jsx`, `Form/GeneralSettings.jsx`, `Form/MeetingLinksSettings.jsx`,
  `constants.js`)
- Show + redeems + reports: `app/javascript/src/components/Settings/DiscountCodes/Show/`
  (`index.jsx`, `Redeems/`, `Reports/`)
- Public input: `app/javascript/src/components/Live/common/DiscountCodeForm.jsx`
- Public applied state: `app/javascript/src/components/Live/Booking/Payment/DiscountSection.jsx`
- Amount summary: `app/javascript/src/components/Live/Booking/Payment/AmountOverviewCard.jsx`
- API hooks: `app/javascript/src/hooks/reactQuery/settings/useDiscountCodesApi.js`

**Target (neeto-form-web) anchors:**
- Org admin panel: `app/javascript/src/components/AdminPanel/` + `app/javascript/src/routes.js`
- Payment element config (enablement section goes here):
  `app/javascript/src/components/Form/Builder/Panels/QuestionProperties/Configure/Properties/QuestionTypes/Payments/`
  and `.../QuestionTypes/ProductList/`
- Public payment elements: `app/javascript/src/components/Form/commons/Elements/Payments/`
- Product List public: `app/javascript/src/components/Form/commons/Elements/ProductList/`
- Pricing hooks: `app/javascript/src/hooks/useProductListPricing.js`, `useParsePaymentAmount`
- Submission service: `app/services/forms/submissions/create_service.rb`
- Org ↔ form ownership + "global resource enabled per form" precedent:
  `app/models/organization.rb`, `app/models/form.rb`,
  `app/models/concerns/payment_management.rb` (payment accounts via `holdable`)

## 10. Edge cases

- Code not enabled for this form → verify returns "invalid code for this form".
- Code expired / disabled / usage-limit reached → specific error; UI shows the matching
  message.
- Code not found → generic "invalid code" error.
- Fixed-amount code currency ≠ form currency → cannot be enabled for that form (guarded
  at enablement); if a form's currency changes after enablement, the code is treated as
  not applicable until re-validated.
- Fixed discount ≥ payable amount → charge clamps to 0.
- Applied code removed by respondent → total reverts to original.
- Race on the last redemption of a usage-limited code → server-side re-validation at
  submission time is authoritative; the redeem record is created transactionally with
  the charge.
- Client-tampered discounted amount → ignored; server recomputes from the code.
- Deleting/disabling a code that is enabled on forms → cascade-clean the join rows;
  in-flight applied codes fail re-validation at submission. Redeem records are retained
  for reporting.

## 11. Permissions

- Managing codes globally (incl. redeems/reports) requires an org-admin permission,
  aligned with how the admin panel sections (roles, webhooks) are gated today.
- Enabling codes from a form's payment element aligns with the form's
  integration/payment management permission (e.g. `MANAGE_INTEGRATIONS`). Final
  permission keys to be confirmed against neeto-form-web's permission constants during
  implementation planning.


## 14. v2 backlog (deferred)

The three respondent-identity features that NeetoForm cannot reliably support yet:
**email whitelists**, **per-email redemption limits**, and **validity window after
first use per client** — revisit once submissions carry a dependable respondent email.
Optionally, shared extraction of the discount-code logic into
`neeto-molecules` / `neetopayments` so NeetoForm and NeetoCal share one implementation.