# Voucher Vending Machine — Technical Requirements & Progress Tracker

**Module:** `voucher_vending_machine`
**Path:** `public_html/sites/nadca/modules/voucher_vending_machine/`
**Site:** NADCA (`nadca`)
**Version:** 9.x-1.0
**Status:** Requirements draft — not yet started

---

## 1. Module Overview

The Voucher Vending Machine module sells third-party (Kryterion) exam vouchers through Association Advantage (AA) and dispenses pre-loaded voucher codes to NADCA members and non-members after purchase.

For v1 the only sellable item is the **ASCS Exam Voucher**, but the data model is built to support additional products (other Kryterion exams, future certifications) without code changes.

### High-level flow

1. **Anonymous or authenticated user** lands at `/nadca-vouchers/{voucher_product}` (e.g. `/nadca-vouchers/12345`) — module renders a controller-driven landing page using the node's title, marketing-copy field, and CTA. Hidden = no menu link, but registered route accessible by direct URL.
   - **Anonymous:** CTA reads "Log in to purchase" → `/user/login?destination=/nadca-vouchers/purchase/{nid}`. Page also displays the note "Member and non-member pricing is shown after you sign in and reach checkout" (since AA shows pricing inside the iframe).
   - **Authenticated:** CTA reads "Purchase now" → `/nadca-vouchers/purchase/{nid}`.
2. Authenticated user reaches `/nadca-vouchers/purchase/{voucher_product}` — module renders an AA iframe (same pattern as `lmsPurchase::aaPage()`). AA enforces the correct price tier based on the logged-in `field_ams_id`. The page also displays a "Return to your vouchers" button above the iframe (same UX as the LMS "Return to Courses" pattern). **At this moment the controller writes a `vvm_pending_sync` flag to the user's Drupal session** — this is the only reliable "purchase intent / cache is now suspect" signal we have, since the existing `AMS_IFRAME_RENDER_PAGE_URL` cookie fires for *any* iframe, not just voucher purchases.
3. After checkout in AA, the user clicks the "Return to your vouchers" button (or just navigates back, or returns later — the session flag persists). The dashboard finds the flag, force-syncs once, then clears it. Sync button is always available as a fallback.
4. Sync calls `commerceServices::userPurchases($ams_id)` (after busting the AMS cache), finds purchases whose `productSerno` matches a configured voucher product, and for each unit of `quantity` not yet assigned to the user, pulls one unassigned voucher-code entity from the pool and binds it to that user.
5. The dashboard lists the user's voucher codes, purchase dates, and the registration URL for the Kryterion testing site.
6. NADCA staff use an admin dashboard to upload Kryterion-generated codes (CSV or paste textarea), see inventory metrics, and configure thresholds / alert recipients.
7. An Ultimate Cron job checks each voucher product's available-code count against its low-inventory threshold and emails the configured recipients when supply drops below it.

### Out of scope (v1)

- Code redemption status tracking (`assigned → redeemed → expired`). Once a code is bound to a user it is considered terminal from this module's side. Kryterion does not push back redemption data.
- Refunds / un-binding a voucher (handled manually by NADCA staff via the admin UI if it ever comes up).
- Drupal Commerce. All money movement happens in AA, identical to the existing LMS purchase flow.

---

## 2. Architecture & Integration Points

### Existing services this module depends on

| Service / API | What we use it for | Source |
|---|---|---|
| `commerceServices::userPurchases($ams_id)` | Pull a user's AA orders, keyed by `productSerno`, with `quantity` per line. | `public_html/modules/custom/ams/src/Controller/aa/commerceServices.php:75` |
| `commerceServices::allAmsProducts()` | Admin product picker (list of AA products to map to voucher products). | `public_html/modules/custom/ams/src/Controller/aa/commerceServices.php:166` |
| `iframe_services::renderIframe($url)` | Render the AA storefront iframe for purchase. | Same pattern as `public_html/modules/custom/lms/src/Controller/lmsPurchase.php:95` |
| `field_ams_id` (user base field) | The customer ID handed to AA's iframe URL and to `userPurchases()`. | Set by `ams` module. |
| `ultimate_cron` | Scheduled inventory-alert job. | Contrib. |

### AA iframe URL pattern (reused verbatim from LMS)

```
ecssashop.show_product_detail?p_product_serno={product_serno}&p_cust_id={ams_id}&p_mode=detail
```

### Cache invalidation on sync

Same keys as LMS. After a sync the controller must:

```php
\Drupal::cache('ams')->delete("Purchases:$ams_id");
Cache::invalidateTags(['user_purchase:' . $ams_id]);
```

…before calling `userPurchases()` so the request actually hits AA.

---

## 3. Data Model

Two new node bundles. Nodes were chosen over a custom entity for ease of entity reference, Views integration, and Features export — matches conventions elsewhere in the site.

### 3.1 Voucher Product (`voucher_product`)

Represents a sellable thing (e.g., "ASCS Exam Voucher"). Admin-managed. One row per product. Usually a small set (1 in v1, maybe a handful long-term).

| Field | Machine name | Type | Required | Notes |
|---|---|---|---|---|
| Title | `title` | String (base) | Yes | Human-readable name, e.g. "ASCS Exam Voucher". Also used as page title for the landing route. |
| AA Product Serno | `field_vvm_aa_product_serno` | String | Yes | Matches `productSerno` returned by `userPurchases()`. The lookup key. |
| AA Product Code | `field_vvm_aa_product_code` | String | No | Display / reporting only. `productId` from AA. |
| Landing Page Copy | `field_vvm_marketing_html` | Text (formatted, long) | No | Rich-text marketing copy rendered on the public landing page at `/nadca-vouchers/{this_node}`. NADCA staff edits this per-product. |
| Registration URL | `field_vvm_registration_url` | Link | Yes | Kryterion test-site URL (TBD) shown to user after dispense. |
| Registration Instructions | `field_vvm_registration_html` | Text (formatted, long) | No | Rich-text block shown alongside the code on the user dashboard (post-purchase). |
| Low-inventory threshold | `field_vvm_low_threshold` | Integer | Yes | Cron alert fires when unassigned count for this product drops at or below this. |
| Active | `field_vvm_active` | Boolean | Yes | Inactive products are hidden from landing + purchase routes and excluded from alerts. |

**Status:** Not started

- [ ] Create node type config YAML
- [ ] Create field storage configs
- [ ] Create field instance configs
- [ ] Create form display config
- [ ] Create view display config (default)
- [ ] Disallow anonymous create/edit; admin-only via permissions

### 3.2 Voucher Code (`voucher_code`)

Represents a single Kryterion-issued code. One node per code. This bundle WILL grow large over time — index its key fields, never list it un-paginated.

| Field | Machine name | Type | Required | Notes |
|---|---|---|---|---|
| Title | `title` | String (base) | Yes | The voucher code itself. Used as the natural identifier. |
| Voucher Product | `field_vvm_product` | Entity reference → `node:voucher_product` | Yes | Which product pool this code belongs to. |
| Assigned User | `field_vvm_assigned_user` | Entity reference → `user` | No | NULL = unassigned / available. Setting this is the dispense action. |
| Assigned Date | `field_vvm_assigned_date` | Timestamp | No | Set at dispense time. |
| AA Order Reference | `field_vvm_aa_order_ref` | String | No | `productSerno` + purchase_date or similar; for traceability. |
| Upload Batch ID | `field_vvm_upload_batch` | String | No | UUID per upload, for "show me everything from yesterday's upload" queries. |
| Revoked | `field_vvm_revoked` | Boolean (default 0) | No | TRUE only after staff clicks **Archive** on the Assignments page. Hides the row from user / inventory / metrics but preserves the assignment for audit. NEVER set on a Recycle (recycled codes look identical to fresh available stock). |
| Admin Notes | `field_vvm_admin_notes` | Text long (plain_text) | No | Append-only audit log written by the Recycle / Archive forms. Survives the recycle path so the historical "who had this code, when, and why was it revoked" record is preserved without depending on node revisions. |

**Voucher code state table** (the four states the (`assigned_user`, `revoked`) pair can express):

| State | `assigned_user` | `revoked` | Notes |
|---|---|---|---|
| Available | NULL | 0 | Fresh stock, eligible for dispense |
| Assigned | uid | 0 | Live user code, visible on user dashboard |
| Recycled (back in pool) | NULL | 0 | Identical shape to "Available" — `admin_notes` records prior assignment |
| Archived (revoked) | uid | 1 | Kept for audit, hidden from every live view, excluded from dispense |

**Indexing concerns:**

- `field_vvm_product` + `field_vvm_assigned_user IS NULL` is the single hottest query (the "pull next available code" path). The default Drupal field-data table indexes are sufficient for tens of thousands of rows; if the pool ever grows past that, add a composite index in `hook_install()`.
- Title (the code string) must be unique within a `voucher_product`. Enforced at upload time by querying existing nodes; double-write is acceptable risk for v1.

**Access:**

- Authors can never edit voucher codes directly via the node UI — restrict the bundle from the standard add/edit menus, lock down permissions. All mutation happens through this module's controllers and forms.
- Authenticated users can view *only their own* assigned codes, via the user dashboard route (custom access check), never via the canonical node route. Block the canonical `/node/{nid}` view path with `hook_node_access()` or by hiding the bundle from the node grants.

**Admin UX — voucher_code is invisible to general content management:**

- Removed from `/admin/content` (the default `content` view): `hook_views_query_alter` filters out `voucher_code` rows server-side, and `hook_form_alter` strips `voucher_code` from the exposed "Content type" filter dropdown so it doesn't even appear as a filter option.
- Removed from `/node/add` for non-admins: `hook_node_create_access` denies create access, which `NodeController::addPage` honors automatically.
- Direct edit at `/node/{id}/edit` blocked for non-admins: `hook_form_alter` throws `AccessDeniedHttpException` on the canonical edit form.
- The only ways to create or modify voucher codes are the CSV upload form at `/admin/voucher-vending-machine/upload` and the automatic dispense action in `VoucherDispenser::sync()`.

**Status:** Not started

- [ ] Create node type config YAML
- [ ] Create field storage configs
- [ ] Create field instance configs
- [ ] Form display config (admin only, used by upload flow)
- [ ] View display config (default — used in dashboard render arrays only)
- [ ] `hook_node_access()` to deny canonical view to anyone but the assigned user + admin
- [ ] Hide bundle from `/node/add` for all non-admin roles

---

## 4. Routes

All routes registered, none in menu links (with the exception of the admin link in `voucher_vending_machine.links.menu.yml`).

| Path | Controller / Form | Access | Notes |
|---|---|---|---|
| `/nadca-vouchers/{node}` | `LandingController::page` | Public; node must be `voucher_product`, `field_vvm_active = TRUE`, with non-empty serno | Public landing page. Renders title + `field_vvm_marketing_html` + auth-aware CTA. Anonymous → "Log in to purchase" with `?destination=…/purchase/{nid}`. Authenticated → "Purchase now" → iframe route. |
| `/nadca-vouchers/purchase/{node}` | `PurchaseController::page` | `_user_is_logged_in: 'TRUE'` AND voucher_product node is `field_vvm_active = TRUE` AND has serno | Renders the AA iframe with "Return to your vouchers" button prepended. **Side effect: writes `vvm_pending_sync` flag to user session** so the dashboard knows to auto-sync next time. |
| `/user/vouchers` | `UserDashboardController::page` | Authenticated. Auto-routes to current user. | Lists user's voucher codes + Sync button. |
| `/user/vouchers/sync` | `UserDashboardController::sync` (POST only) | Authenticated + CSRF. | Clears AMS purchase cache, calls `userPurchases()`, runs dispenser, redirects back to dashboard with status message. |
| `/admin/voucher-vending-machine` | `AdminDashboardController::page` | `administer voucher vending machine` permission | Metrics dashboard. |
| `/admin/voucher-vending-machine/upload` | `CsvUploadForm` | `administer voucher vending machine` | CSV upload OR textarea paste. |
| `/admin/voucher-vending-machine/assignments` | `AssignmentsController::page` | `administer voucher vending machine` | Per-user listing of issued codes; filters by AMS ID / name / email / product; per-row Recycle / Archive actions. |
| `/admin/voucher-vending-machine/assignments/{node}/revoke?op=recycle\|archive` | `RevokeForm` | `administer voucher vending machine` | Confirm form for the two revoke ops. |
| `/admin/voucher-vending-machine/settings` | `SettingsForm` | `administer voucher vending machine` | Alert recipients, default threshold, cron settings. |

**Status:** Not started

- [ ] `voucher_vending_machine.routing.yml`
- [ ] `voucher_vending_machine.links.menu.yml` (admin menu link only)
- [ ] `voucher_vending_machine.permissions.yml` (`administer voucher vending machine`, `view own vouchers`)

---

## 5. Controllers, Forms, Services

```
src/
├── Controller/
│   ├── LandingController.php
│   ├── PurchaseController.php
│   ├── UserDashboardController.php
│   ├── AdminDashboardController.php
│   └── AssignmentsController.php   (the admin per-user assignment list)
├── Form/
│   ├── SettingsForm.php
│   ├── CsvUploadForm.php
│   ├── ManualSyncForm.php          (the Sync button on the user dashboard)
│   └── RevokeForm.php              (Recycle / Archive confirm form)
└── Service/
    ├── VoucherDispenser.php        (the core sync-and-bind logic)
    └── VoucherInventory.php        (counts, alert evaluation, alert send)
```

### 5.1 `VoucherDispenser` (service)

**Responsibility:** given a `$account`, reconcile their AA purchases with their currently-assigned voucher codes and bind new ones as needed.

```
sync($account):
  ams_id = $account->field_ams_id->value
  if empty(ams_id): return ['error' => 'no_ams_id']

  // Force-bust the AMS cache so we hit AA live.
  \Drupal::cache('ams')->delete("Purchases:$ams_id")
  Cache::invalidateTags(['user_purchase:' . $ams_id])

  purchases = commerceServices::userPurchases(ams_id)   // keyed by productSerno
  results = []

  foreach voucher_product node where field_vvm_active = TRUE:
    serno = product.field_vvm_aa_product_serno

    if !isset(purchases[serno]): continue
    if purchases[serno]['status'] is a refunded/voided status: continue

    qty_purchased = (int) purchases[serno]['quantity']
    qty_assigned  = count(voucher_code nodes WHERE field_vvm_product = product AND field_vvm_assigned_user = $account)
    shortfall     = qty_purchased - qty_assigned

    if shortfall <= 0: continue

    available_codes = query voucher_code nodes
        WHERE field_vvm_product = product
          AND field_vvm_assigned_user IS NULL
        ORDER BY nid ASC
        LIMIT $shortfall
        // do this inside a transaction so two concurrent syncs can't grab the same code

    foreach available_codes as code:
      code.field_vvm_assigned_user = $account
      code.field_vvm_assigned_date = REQUEST_TIME
      code.field_vvm_aa_order_ref  = serno . ':' . purchases[serno]['purchase_date']
      code.save()

    results[product.id] = [
      'expected' => qty_purchased,
      'now_assigned' => qty_assigned + count(bound),
      'shortfall_unmet' => shortfall - count(bound),     // > 0 means inventory ran out mid-sync
    ]

  return results
```

**Concurrency note:** the "pick next N unassigned codes" query must run inside a transaction with row locking (`SELECT … FOR UPDATE` via `\Drupal::database()->select()->forUpdate()`). Otherwise two simultaneous sync requests for the same user could assign the same code twice. v1 is probably fine without this given low traffic, but it's a 10-line addition and worth doing up front.

**Inventory-ran-out case:** if `shortfall_unmet > 0` for any product, the dispenser must fire the same alert email as cron, with subject "URGENT: voucher pool exhausted during user sync."

### 5.2 `VoucherInventory` (service)

- `counts()` → array of `[voucher_product_nid => ['total' => N, 'assigned' => N, 'available' => N, 'threshold' => N, 'below_threshold' => bool]]`
- `evaluateAlerts()` → returns products currently below threshold, called by cron.
- `sendAlertEmail($products_below)` → sends one combined email to configured recipients.

### 5.3 `CsvUploadForm`

- Two input modes: file upload (CSV, single column, header optional and auto-detected) and textarea (paste comma or newline-separated codes).
- Required form field: target Voucher Product (select list of active products).
- Validation:
  - Strip whitespace, ignore blank lines, deduplicate within the submission.
  - Query existing `voucher_code` titles for the selected product; reject the whole submission with a clear list if duplicates are found. (Alternatively: report and skip — TBD with NADCA staff.)
- On submit: generate a single `upload_batch` UUID, create one `voucher_code` node per code, save with `field_vvm_product` set, `field_vvm_assigned_user` NULL. Show summary: "Added 50 codes to the ASCS Exam Voucher pool. Available: 50. Upload batch: `abc-123`."
- Wrap creation in a transaction so a mid-batch failure rolls back cleanly.

### 5.4 `AdminDashboardController` — metrics shown

Per voucher product:

- **Total codes** (lifetime)
- **Available** (unassigned)
- **Used** (assigned)
- **Low threshold** (configured value)
- **Status** — visual indicator (see Styling below)
- **Last code added** (timestamp + relative)
- **Last code assigned** (timestamp + relative)
- **Codes added — last 7 days / last 30 days**
- **Codes assigned — last 7 days / last 30 days**

Site-wide footer:

- **Last alert sent** (timestamp + which products triggered)
- **Last 10 uploads** (date, product, count, admin user)

#### Styling for low inventory

- **Status: Healthy** (available > 2× threshold) — green pill / check icon.
- **Status: Approaching** (threshold < available ≤ 2× threshold) — amber pill / warning icon, amber row border.
- **Status: Low** (available ≤ threshold) — red pill / alert icon, red row border, bold red "Available" count, plus a prominent banner at the top of the dashboard summarizing all products in this state.
- **Status: Empty** (available = 0) — red banner that takes over the top of the page, "Cannot fulfill orders" warning, sticky until at least one code is uploaded.

All styling will be plain CSS in `css/voucher-admin-dashboard.css`, no external libs.

---

## 5.5 Assignments admin page + Revoke flow

Route: `/admin/voucher-vending-machine/assignments` (linked from the main admin dashboard header + menu link "Voucher assignments").

**Purpose.** Single place for NADCA staff to find any issued voucher code by user attributes and recover it after a refund / chargeback / accidental dispense. Replaces the implicit "ask Mike to run a db query" workflow.

**Layout.**
- Filter row (GET-driven, no JS): AMS ID, full name, email (all substring `LIKE`), product (select), "Show archived" checkbox.
- Below filters, one block per user ordered by most-recent assignment DESC, paginated 25 users per page.
  - User block header: full name (`field_full_name` → first+last → username fallback), email, AMS ID, code count, newest assignment date, edit-user link.
  - Inner table: code (monospaced) | product | assigned date | AA order ref | actions.
- Pager at the bottom (standard Drupal pager element 0).

**Actions per code row.** Both write an append-only entry to `field_vvm_admin_notes`:

1. **Return to pool** (`?op=recycle`)
   - Clears `field_vvm_assigned_user`, `field_vvm_assigned_date`, `field_vvm_aa_order_ref`.
   - Leaves `field_vvm_revoked = 0` (the code is now indistinguishable from fresh inventory).
   - Use when the buyer was refunded and the code was NOT redeemed at Kryterion. The next purchaser will be assigned this code by the dispenser.

2. **Archive** (`?op=archive`)
   - Sets `field_vvm_revoked = 1`.
   - Keeps `field_vvm_assigned_user` + dates intact for audit.
   - Use when the buyer was refunded but the code was already redeemed (or when there's any doubt). Hidden from user dashboard, admin counts, and the default assignments view. Visible only with "Show archived" checked.

Both forms accept an optional staff "Reason" string that is appended to the audit-log entry.

**Filter behavior.** Filters operate against the voucher_code → assigned_user → users_field_data join with `LIKE` on the relevant user fields. Active vs. archived is mutually exclusive — toggling "Show archived" swaps the result set entirely, so staff don't confuse live and revoked codes.

**Why no separate `recycled_at` field?** Recycling is the *absence* of a problem — the code is just available again. The append-only audit log is the only thing that needs to remember the prior assignment; the data model itself reverts to "fresh stock" so the dispenser's `assigned_user IS NULL AND revoked = 0` predicate doesn't need a special case.

---

## 6. Cron — Low Inventory Alerts

Implementation: Ultimate Cron job named `voucher_vending_machine_inventory_alert`.

- Default schedule: every 6 hours. Editable via Ultimate Cron's standard UI.
- Job logic:
  1. For each active voucher product, compute available count vs. `field_vvm_low_threshold`.
  2. Collect those that are at or below threshold.
  3. If empty, do nothing.
  4. If non-empty, compare against a per-product "last alerted at" record in state (`voucher_vending_machine.last_alert.{nid}`). Re-alert only if at least 24 hours have passed OR if the available count has dropped further since the last alert. (Avoids spam.)
  5. Send one email to all configured recipients with the list of low products, current counts, and a deep link to the admin upload form.
- Settings form fields:
  - **Alert recipient email addresses** — textarea, one per line.
  - **Alert "from" address** — defaults to site default.
  - **Minimum hours between re-alerts** — integer, defaults to 24.

**Status:** Not started

- [ ] Implement `VoucherInventory::evaluateAlerts()`
- [ ] Implement `VoucherInventory::sendAlertEmail()`
- [ ] Register Ultimate Cron job (via `hook_cron_job_info()` or equivalent for the installed version of ultimate_cron)
- [ ] Implement `hook_mail()` for the alert template
- [ ] Add settings form fields for recipients / re-alert window

---

## 7. User Dashboard

Route: `/user/vouchers`. Authenticated only. Renders the current user's view.

### Sections (top to bottom)

1. **Page header** — "Your Exam Vouchers"
2. **Sync banner** — short copy + "Sync purchases" button (POSTs to `/user/vouchers/sync`). Disabled / replaced with a spinner while sync runs.
3. **Per-product accordion or section list:**
   - Product name (e.g. "ASCS Exam Voucher")
   - For each assigned code:
     - The code, styled prominently (monospaced, copy-to-clipboard button)
     - Assigned date
     - Registration URL (button: "Schedule your exam")
     - Registration instructions block
4. **Empty state** — if the user has no codes: copy explaining they need to purchase first, with a link to the NADCA basic page about the ASCS exam.
5. **Mismatch state** — if sync determined `shortfall_unmet > 0`: a red notice "You have N pending voucher(s) that we could not fulfill — NADCA has been notified."

### Sync UX — when does auto-sync fire?

Single trigger: the `vvm_pending_sync` session flag written by `PurchaseController` when the user visited a voucher purchase iframe.

```
on dashboard load:
  $flag = session->get('vvm_pending_sync')
  if $flag is set AND (now - $flag['set_at']) < 86400:   // 24h max age
    VoucherDispenser::sync($current_user)
    session->remove('vvm_pending_sync')
```

Behavior by scenario:

| Scenario | Auto-sync? | Why |
|---|---|---|
| User completes purchase, clicks "Return to your vouchers" | Yes | Flag written when iframe rendered, found on dashboard, force-sync runs, codes appear |
| User completes purchase, closes tab, opens dashboard later | Yes | Session flag persists for the lifetime of the Drupal session (~23 days default) |
| User opens iframe, abandons cart in AA, returns to dashboard | Yes (one wasted AA round-trip — bounded, acceptable) | We can't distinguish abandon from completion. Worst case is one cache-busted `userPurchases()` call. |
| Returning user with no new purchase, no recent iframe visit | No | No flag → no auto-sync → no AA round-trip on every dashboard load |
| Session expired / very stale return | No | Manual Sync button handles it |

**Why session, not cookie:**
- Server-side, HttpOnly, scoped to the authenticated user — safer than a client-controlled cookie.
- Drupal sessions are durable across browser restarts within the session lifetime.
- The existing `AMS_IFRAME_RENDER_PAGE_URL` cookie is not viable as a substitute: it's set on *every* iframe render (profile updates, store browsing, etc.), and is cleared by other controllers like `profile.php::updateAccount()`. It would produce false positives and false negatives.

**Manual sync button** stays in the UI permanently as the support-case fallback — it always works, regardless of session state.

---

## 8. Permissions

| Permission | Roles (proposed) |
|---|---|
| `administer voucher vending machine` | administrator, NADCA staff role TBD |
| `view own vouchers` | authenticated user |

The canonical `/node/{nid}` view of a voucher_code node must be denied to everyone except the assigned user and admins (via `hook_node_access`). Admin can still edit via the node form, but the form is not linked from any menu.

---

## 9. File Structure (Target)

```
voucher_vending_machine/
├── voucher_vending_machine.info.yml
├── voucher_vending_machine.module
├── voucher_vending_machine.routing.yml
├── voucher_vending_machine.links.menu.yml
├── voucher_vending_machine.permissions.yml
├── voucher_vending_machine.libraries.yml
├── voucher_vending_machine.features.yml
├── voucher_vending_machine.install
├── voucher_vending_machine_requirements.md
├── config/
│   └── install/
│       ├── node.type.voucher_product.yml
│       ├── node.type.voucher_code.yml
│       ├── field.storage.node.field_vvm_aa_product_serno.yml
│       ├── field.storage.node.field_vvm_aa_product_code.yml
│       ├── field.storage.node.field_vvm_registration_url.yml
│       ├── field.storage.node.field_vvm_registration_html.yml
│       ├── field.storage.node.field_vvm_low_threshold.yml
│       ├── field.storage.node.field_vvm_active.yml
│       ├── field.storage.node.field_vvm_product.yml
│       ├── field.storage.node.field_vvm_assigned_user.yml
│       ├── field.storage.node.field_vvm_assigned_date.yml
│       ├── field.storage.node.field_vvm_aa_order_ref.yml
│       ├── field.storage.node.field_vvm_upload_batch.yml
│       ├── field.field.node.voucher_product.field_vvm_*.yml  (one per field)
│       ├── field.field.node.voucher_code.field_vvm_*.yml     (one per field)
│       ├── core.entity_form_display.node.voucher_product.default.yml
│       ├── core.entity_form_display.node.voucher_code.default.yml
│       ├── core.entity_view_display.node.voucher_product.default.yml
│       ├── core.entity_view_display.node.voucher_code.default.yml
│       └── voucher_vending_machine.settings.yml
├── src/
│   ├── Controller/
│   │   ├── PurchaseController.php
│   │   ├── UserDashboardController.php
│   │   └── AdminDashboardController.php
│   ├── Form/
│   │   ├── SettingsForm.php
│   │   ├── CsvUploadForm.php
│   │   └── ManualSyncForm.php
│   └── Service/
│       ├── VoucherDispenser.php
│       └── VoucherInventory.php
├── templates/
│   ├── voucher-user-dashboard.html.twig
│   ├── voucher-product-section.html.twig
│   └── voucher-admin-dashboard.html.twig
├── css/
│   ├── voucher-user-dashboard.css
│   └── voucher-admin-dashboard.css
└── js/
    └── voucher-copy-to-clipboard.js
```

---

## 10. Module Info

```yaml
# voucher_vending_machine.info.yml
name: 'Voucher Vending Machine'
type: module
description: 'Sells third-party exam vouchers through Association Advantage and dispenses pre-loaded voucher codes to purchasers. Built for NADCA.'
package: 'NADCA'
core_version_requirement: '^8.9 || ^9'
version: 9.x-1.0
dependencies:
  - drupal:node
  - drupal:user
  - drupal:link
  - drupal:datetime
  - ams:ams
  - ams_iframes:ams_iframes
  - ultimate_cron:ultimate_cron
  - features:features
```

---

## 11. Open Questions / Decisions Pending

These don't block scaffolding the module but need answers before the relevant subsystem is built:

1. **Kryterion CSV format confirmation** — single column for v1 is assumed. If the real CSV has columns like `expiration_date`, we'll add a corresponding field on `voucher_code` and surface it in the user dashboard.
2. **Duplicate handling on upload** — reject whole submission or skip and report? Default in spec is "reject whole submission" for safety.
3. ~~**Refund / order-status semantics** — `commerceServices::userPurchases()` returns a `status` field. Which values mean "treat as voided / don't dispense"?~~ **Resolved 2026-05-13:** AA's `ecssawebsvclib.get_purchased_products_xml` is documented as returning "a list of purchased products" and the existing `lms_unit_purchased()` / `commerceServices::isPurchased()` precedent does no status filtering — membership in the returned array is treated as the entire truth. We follow the same precedent: the dispenser dispenses for any purchase that's in the array. Refund recovery is handled out-of-band by NADCA staff via the **Recycle / Archive** actions on the Assignments admin page. If a real refund-related edge case ever surfaces, we'll add status filtering then with the actual status string in hand.
4. **The Kryterion testing site URL** — TBD per brief; we'll let staff edit per-product on the voucher_product node.
5. ~~**Hidden purchase page on NADCA.com** — confirming this is a *manually edited basic page* in the existing CMS, NOT created by this module.~~ **Resolved 2026-05-12: this module now renders the landing page itself via `LandingController` at `/nadca-vouchers/{voucher_product}`, driven by `field_vvm_marketing_html` on the voucher_product node. No basic page involvement.**
6. **Auto-sync on dashboard load — frequency** — every load, or once per session, or once per hour per user? Spec currently says "only when shortfall is detected," which is the safe minimum. Could be relaxed if performance is fine.
7. **Email recipient management** — single textarea in admin settings is the v1 plan. If recipients need per-product routing later, we'll move to a config entity.
8. **Permission to upload codes** — is "administer voucher vending machine" granular enough, or do we need a separate "upload voucher codes" permission for staff who can upload but not see metrics? Default: one permission for v1.

---

## 12. Progress Tracker

Phases are roughly ordered by dependency. Each box maps to a discrete deliverable.

### Phase 1 — Scaffolding ✅

- [x] Create `voucher_vending_machine.info.yml`
- [x] Create `voucher_vending_machine.module` (with constants, theme hooks, access hooks, mail hook, cron callback)
- [x] Create `voucher_vending_machine.permissions.yml`
- [x] Create `voucher_vending_machine.install` (uninstall cleanup)
- [x] Module enables cleanly on NADCA (`drush --uri=nadca en voucher_vending_machine` → success)

### Phase 2 — Data model ✅

- [x] `node.type.voucher_product.yml`
- [x] All `field.storage` + `field.field` configs for `voucher_product` (6 fields)
- [x] Form + view display configs for `voucher_product`
- [x] `node.type.voucher_code.yml`
- [x] All `field.storage` + `field.field` configs for `voucher_code` (5 fields)
- [x] Form + view display configs for `voucher_code`
- [x] `hook_node_access()` denying canonical view of voucher_code to non-owners
- [x] `hook_node_create_access()` hides both bundles from `/node/add` for non-admins
- [x] `hook_form_alter()` blocks direct voucher_code node-form access for non-admins
- [x] `hook_views_query_alter()` filters voucher_code out of `/admin/content` view
- [x] `hook_form_alter()` strips voucher_code from the exposed "Content type" filter dropdown on `/admin/content`
- [x] Verified bundles + all 11 fields installed on NADCA via `drush php:eval`

### Phase 3 — CSV upload + admin metrics ✅

- [x] `CsvUploadForm` — CSV file and textarea modes, auto-detects single-column header
- [x] Duplicate-check validation rejects entire submission with sample of duplicates
- [x] Upload-batch UUID + transactional creation (rollback on mid-batch failure)
- [x] `VoucherInventory::counts()` service with days-of-supply heuristic
- [x] `VoucherInventory::recentUploads()` for the admin dashboard footer
- [x] `AdminDashboardController` + `voucher-admin-dashboard.html.twig`
- [x] `voucher-admin-dashboard.css` (Healthy / Approaching / Low / Empty styling + banners)
- [x] Admin menu link under Configuration → workflow (`links.menu.yml`)
- [x] Manual QA: created product + 5 codes via php:eval, inventory.counts() returned correct shape with status="approaching"

### Phase 4 — Landing page + purchase iframe route ✅

- [x] `LandingController::page` — controller-rendered public landing at `/nadca-vouchers/{node}` (replaces the originally-planned basic page)
- [x] `field_vvm_marketing_html` field added to `voucher_product` for landing copy
- [x] Auth-aware CTA (anonymous → login w/ destination → returns to landing; authenticated → iframe)
- [x] `voucher-landing.html.twig` + `voucher-landing.css`
- [x] `PurchaseController::page` mirrors `lmsPurchase::aaPage` (uses `AMS_IFRAME_SERVICE`)
- [x] Route + `_custom_access` check (logged-in + `field_vvm_active` + non-empty serno)
- [x] Cache reset for AMS purchases on iframe render
- [x] Writes `vvm_pending_sync` session flag with `set_at` + accumulated `product_nids`
- [x] Prepends "Return to your vouchers" button above the iframe (LMS pattern)
- [x] Both routes registered correctly; URL matcher confirmed `/nadca-vouchers/{nid}` → landing, `/nadca-vouchers/purchase/{nid}` → iframe
- [ ] Manual QA with real AA test account: requires NADCA-provided sandbox login

### Phase 5 — Sync + dispense ✅

- [x] `VoucherDispenser::sync()` with `SELECT … FOR UPDATE` inside a transaction
- [x] `ManualSyncForm` (the user dashboard Sync button)
- [x] `UserDashboardController` auto-consumes the pending-sync flag on load
- [x] Auto-sync only fires when session flag is < 24h old; flag cleared after consumption
- [x] Inventory-exhausted-mid-sync sends urgent alert email (not throttled)
- [x] Service boots cleanly (`\Drupal::service('voucher_vending_machine.dispenser')`)
- [ ] Manual QA: end-to-end purchase in AA sandbox → return-button → see code on dashboard
- [ ] Manual QA: open purchase iframe, abandon, return to dashboard — auto-sync runs once, flag clears

### Phase 6 — User dashboard ✅

- [x] `UserDashboardController::page` (groups codes by product, renders empty/shortfall states)
- [x] `voucher-user-dashboard.html.twig` + `voucher-product-section.html.twig`
- [x] `voucher-user-dashboard.css` (monospaced code blocks, copy-button styling, Schedule CTA)
- [x] `voucher-copy-to-clipboard.js` (uses `navigator.clipboard` with execCommand fallback)
- [x] Empty state copy when user has no codes
- [x] Shortfall message when sync detected pool exhaustion
- [ ] Manual QA in a browser session: needs a real authenticated NADCA user

### Phase 7 — Inventory alerts ✅

- [x] `SettingsForm` (recipients textarea, from address, re-alert window)
- [x] `voucher_vending_machine.settings.yml` defaults (realert_hours=24)
- [x] `hook_mail()` builds HTML email body
- [x] `VoucherInventory::evaluateAlerts()` + `shouldReAlert()` + `sendAlertEmail()`
- [x] `voucher_vending_machine_check_inventory()` cron callback
- [x] `ultimate_cron.job.voucher_vending_machine_inventory_alert.yml` (every 6h crontab)
- [x] Ultimate Cron job registered in NADCA DB
- [ ] Manual QA: set a low threshold, run cron, verify email sent; raise threshold, verify silence

### Phase 8 — Features export & docs

- [x] `voucher_vending_machine.features.yml` (`required: true`)
- [x] All config lives under `config/install/` (Features will pick it up on first `drush fex`)
- [ ] Run `drush fex` once a real product is configured, to capture any post-install drift
- [ ] Commit & open PR to NADCA fork

### Phase 9 — Open Questions Closed (depends on NADCA staff input)

- [ ] CSV column structure confirmed with NADCA (currently assuming single column)
- [ ] Duplicate-handling policy confirmed (currently: reject entire submission)
- [x] ~~AA order-status values for "voided" confirmed~~ — closed 2026-05-13. We follow the LMS precedent (no status filtering) and handle refund recovery via the new Assignments admin page (Recycle / Archive).
- [ ] Production testing-site URL provided per voucher_product
- [ ] Production alert recipients provided
- [ ] Auto-sync cadence reviewed after first month of usage

### Phase 10 — Assignments admin page + Revoke flow ✅

- [x] `field_vvm_revoked` (boolean) + `field_vvm_admin_notes` (text long) added to `voucher_code`
- [x] `hook_update_9001` installs the two fields via the entity API on already-deployed sites
- [x] `AssignmentsController::page` — paginated, filterable per-user grouping
- [x] `RevokeForm` — single confirm form, `op=recycle` clears assignment / `op=archive` sets revoked=1
- [x] Both ops append an audit-log entry to `field_vvm_admin_notes` (date, action, who, prior assignee, AA ref, optional reason)
- [x] `VoucherDispenser` excludes revoked codes from `countAssignedForUser` and from the `bindUnassignedCodes` query
- [x] `VoucherInventory::counts()` excludes revoked codes from `total` / `assigned` / `available` and from recent-activity heuristics
- [x] `UserDashboardController::buildProductSections` filters out revoked codes
- [x] Admin dashboard header + menu link "Voucher assignments"
- [x] Smoke-tested: 3 codes → Recycle one + Archive one → page shows only the untouched code, archived-filter shows the archived one, inventory counts correct, archived code hidden from user dashboard

---

## 13. Change Log

| Date | Author | Notes |
|---|---|---|
| 2026-05-12 | Mike + Claude | Initial draft. Awaiting CSV format, refund-status semantics, recipient list. |
| 2026-05-13 | Mike + Claude | Removed `VOIDED_STATUSES` filtering to align with LMS precedent (no status check in `commerceServices::isPurchased()`). Added `field_vvm_revoked` + `field_vvm_admin_notes` and the `/admin/voucher-vending-machine/assignments` page with Recycle / Archive actions. Smoke-tested end-to-end. |
| 2026-05-14 | Mike + Claude | UX polish: auto-default the upload-product select when only one product exists; restrict `/settings` route + dashboard link to administrator role; field-specific placeholders on assignments filters; removed "Days of supply" metric; standardized all user-visible date displays to `m/d/Y h:ia`. |
