# 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.