Add ticket-voucher entitlement lookup + document both public APIs

GET /api/public/ticket-vouchers?key=&email= returns 0/1/2 free tickets based on
donations on/after VOUCHER_SINCE (default 2025-09-04): >= $400 -> 1, >= $1000 -> 2.
Same secret/CORS/rate-limit as donor-eligibility; returns only the count. Cutoff
and thresholds are env-configurable. Documented both lookup APIs (discount +
vouchers) in docs/fluentforms-donor-discount.md with form snippets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-10 06:27:32 +00:00
parent 42043957e0
commit 0b4ad1c99f
5 changed files with 190 additions and 6 deletions

View file

@ -1,8 +1,26 @@
# FluentForms → donor discount lookup
# FluentForms → donor lookup APIs
FluentForms has no native way to query an external database from a field. This
wires it up with a small Custom JS block that calls our secret-gated endpoint
and unlocks a discount when the entered email belongs to a donor/member.
FluentForms has no native way to query an external database from a field. These
wire it up with a small Custom JS block that calls a secret-gated endpoint on
the ticketing backend. Two endpoints are available (same key, CORS, and rate
limit):
| Endpoint | Purpose |
|---|---|
| `GET /api/public/donor-eligibility` | Is this email a donor/member? → unlock a discount |
| `GET /api/public/ticket-vouchers` | How many free tickets has this donor earned? → 0 / 1 / 2 |
Both require `?key=<PUBLIC_LOOKUP_SECRET>`, are rate-limited (30/min/IP), and
CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` (default
`https://tickets.beartariacampgrounds.com`). Neither returns names or dollar
amounts. The secret is visible in page source, so treat it as deterrence, not
security; rotate it by changing `PUBLIC_LOOKUP_SECRET` and redeploying.
---
## Donor discount lookup
Unlocks a discount when the entered email belongs to a donor/member.
## Endpoint
@ -124,3 +142,99 @@ curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SE
If member and donor get the **same** discounted price, simplify: set the donor
price to show when `donor_tier` **is not** `regular`, and you can ignore the
member/donor distinction.
---
## Ticket-voucher entitlement
Returns how many **free tickets** a donor has earned from their giving, based on
donations **on/after a cutoff date** (default `2025-09-04` — "9/4 last year").
```
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
-> {"vouchers": 0} | {"vouchers": 1} | {"vouchers": 2}
```
**Rules** (donations summed on/after the cutoff):
| Total since cutoff | Vouchers |
|---|---|
| ≥ $1000 | 2 |
| ≥ $400 | 1 |
| otherwise | 0 |
**Config** (backend `.env`):
| Var | Default | Meaning |
|---|---|---|
| `VOUCHER_SINCE` | `2025-09-04` | Only donations on/after this date count. Bump each year. |
| `VOUCHER_TIER1_MIN` | `400` | Dollar total for 1 voucher |
| `VOUCHER_TIER2_MIN` | `1000` | Dollar total for 2 vouchers |
> The count is computed from the **dated transaction tables** (online + offline)
> — the master-list rollups have no dates. Only `Paid` transactions count.
### Form snippet
Displays the voucher count and (optionally) sets a hidden field / caps a
quantity. Same pattern as the discount lookup — paste into a Custom HTML element
and set `KEY`.
```html
<div id="voucher-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
<script>
(function () {
var API = "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers";
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
var EMAIL_SELECTOR = 'input[name="email"]';
var statusEl = document.getElementById("voucher-status");
var last = "";
function show(n) {
if (n > 0) {
statusEl.textContent = "🎟️ You've earned " + n + " free ticket" + (n > 1 ? "s" : "") + "!";
statusEl.style.color = "#1b7f3b";
} else {
statusEl.textContent = "";
}
// Optional: write n into a hidden field named "free_tickets" for conditional
// logic / to cap a quantity. (Uses the native setter so FF's Vue model sees it.)
var el = document.querySelector('input[name="free_tickets"]');
if (el) {
var proto = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value");
(proto && proto.set ? proto.set : function (v) { el.value = v; }).call(el, String(n));
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
}
function check(email) {
if (!email || email === last) return;
last = email;
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
.then(function (r) { return r.json(); })
.then(function (d) { show(d && d.vouchers ? d.vouchers : 0); })
.catch(function () { show(0); });
}
function bind() {
var el = document.querySelector(EMAIL_SELECTOR);
if (!el) { return setTimeout(bind, 500); }
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
}
bind();
})();
</script>
```
### Test
```
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# -> {"vouchers":2} (>= $1000 since the cutoff)
```
> **Heads-up on the cutoff:** with `VOUCHER_SINCE=2025-09-04`, everyone currently
> returns `0` because the donation data in NocoDB ends **2025-05-22** — there are
> no transactions after the cutoff yet. Adjust `VOUCHER_SINCE` (or wait for new
> donations to sync) so the window matches real giving.