Skip to content

feat(promo-codes): domain-authorized promo codes for early registration access#525

Open
caseylocker wants to merge 36 commits intomainfrom
feature/promo-codes-early-registration
Open

feat(promo-codes): domain-authorized promo codes for early registration access#525
caseylocker wants to merge 36 commits intomainfrom
feature/promo-codes-early-registration

Conversation

@caseylocker
Copy link
Copy Markdown
Contributor

@caseylocker caseylocker commented Apr 9, 2026

ref: https://app.clickup.com/t/86b952pgc

Summary

  • Implement domain-based early registration access via two new promo code subtypes: DomainAuthorizedSummitRegistrationDiscountCode (with discount) and DomainAuthorizedSummitRegistrationPromoCode (access-only)
  • Add WithPromoCode audience value on SummitTicketType for promo-code-only ticket types
  • Add auto-discovery endpoint (GET /api/v1/summits/{id}/promo-codes/all/discover) that finds matching codes for the authenticated user's email
  • Add auto_apply support to domain-authorized types and existing email-linked types (Member/Speaker)
  • Add QuantityPerAccount enforcement at both discovery and checkout time
  • Comprehensive unit + integration tests covering domain matching, audience filtering, collision avoidance, serialization, discovery, and checkout enforcement

SDS Implementation (12 tasks)

All 12 tasks implemented. All review follow-ups resolved. Two open deviations remain:

  • D3 (SHOULD-FIX): allowed_email_domains validation needs custom rule (currently sometimes|json)
  • D4 (MUST-FIX): TOCTOU window in QuantityPerAccount checkout enforcement — ApplyPromoCodeTask needs to move after ReserveOrderTask in saga chain

Files changed (35 files, +3127/-53)

  • New models: DomainAuthorizedSummitRegistrationDiscountCode, DomainAuthorizedSummitRegistrationPromoCode
  • New traits: DomainAuthorizedPromoCodeTrait, AutoApplyPromoCodeTrait
  • New interface: IDomainAuthorizedPromoCode
  • New serializers, factory updates, validation rules, repository queries
  • Migration: Version20260401150000
  • Discovery endpoint: route, controller, service
  • Checkout enforcement in ApplyPromoCodeTask
  • Unit tests: DomainAuthorizedPromoCodeTest (30 tests)
  • Integration tests: 8 tests in OAuth2SummitPromoCodesApiTest

Test plan

  • php artisan test --filter=DomainAuthorizedPromoCodeTest — all unit tests pass
  • php artisan test --filter="OAuth2SummitPromoCodesApiTest::testDiscover" — discovery integration tests pass
  • Migration up and down run cleanly
  • Manual API test: create domain-authorized promo code, hit discover endpoint, verify response
  • Verify existing promo code types are unaffected (regression)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Domain-authorized promo codes: email-domain eligibility, per-account limits, and remaining-per-account info.
    • Auto-apply flag added to multiple promo/discount code types.
    • Public promo-code discovery endpoint returning codes applicable to the authenticated user.
    • Support for promo-code-only ticket types and inclusion in ticket selection logic.
  • Tests

    • Extensive unit and API tests covering discovery, domain matching, per-account/global limits, serialization, and validation.

caseylocker and others added 10 commits April 8, 2026 14:44
…registration access

Adds two new promo code subtypes (DomainAuthorizedSummitRegistrationDiscountCode
and DomainAuthorizedSummitRegistrationPromoCode) enabling domain-based early
registration access. Adds WithPromoCode ticket type audience value for
promo-code-only distribution. Includes auto-discovery endpoint, per-account
quantity enforcement at checkout, and auto_apply support for existing
member/speaker promo code types.

SDS: doc/promo-codes-for-early-registration-access.md

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Task 1: Add ClassName discriminator ENUM widening in migration, add
  data guard before narrowing Audience ENUM in down()
- Task 2: Guard matchesEmailDomain() against emails missing @ to
  prevent false-positive suffix matches
- Task 3: Replace canBeAppliedTo() with direct collection membership
  check in addTicketTypeRule() (Truth #4), override removeTicketTypeRule()
  to prevent parent from re-adding to allowed_ticket_types

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Task 5: accepted NITs for constant naming, interface gap, and
pre-existing edge cases.
Task 7: MUST-FIX — canBuyRegistrationTicketByType() missing
WithPromoCode branch blocks checkout for promo-code-only tickets.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Replace 'sometimes|json' with custom AllowedEmailDomainsArray rule for
  allowed_email_domains validation — accepts pre-decoded PHP array and
  validates each entry against @domain.com/.tld/user@email formats
- Remove json_decode() from factory populate for both domain-authorized
  types — value is already a PHP array after request decoding
- Fix expand=allowed_ticket_types silently dropping field on
  DomainAuthorizedSummitRegistrationDiscountCodeSerializer — extend
  re-add guard to check both $relations and $expand
- Rename json_array → json_string_array in both new serializers

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
Add WithPromoCode branch to canBuyRegistrationTicketByType() so promo-code-only
ticket types are not rejected at checkout for both invited and non-invited users.
Replace isSoldOut() with canSell() in the strategy's WithPromoCode loop to align
listing visibility with checkout enforcement. Add 5 unit tests for audience-based
filtering scenarios required by Task 7 DoD.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Task 8: wrap INSTANCE OF chain in parentheses to preserve summit
scoping, simplify speaker email matching via getOwnerEmail(), and
exclude cancelled tickets from quantity-per-account count.

Task 9: add remaining_quantity_per_account (null) to all four
member/speaker serializers, re-add allowed_ticket_types to member
and speaker discount code serializers, and declare setter/getter
on IDomainAuthorizedPromoCode interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…ity_per_account

Move ApplyPromoCodeTask after ReserveOrderTask in the saga chain so
ticket rows exist when the count query fires. Broaden
getTicketCountByMemberAndPromoCode to include 'Reserved' orders,
ensuring concurrent checkouts correctly see each other's reservations.
Remove the TOCTOU-vulnerable pre-check from PreProcessReservationTask
and relocate it inside ApplyPromoCodeTask's locked transaction, where
it naturally fires once per unique promo code.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…sion, canBeAppliedTo, discovery, checkout

- Fix base class: extend Tests\TestCase instead of PHPUnit\Framework\TestCase (boots Laravel facades)
- Add 3 collision avoidance tests for DomainAuthorizedSummitRegistrationDiscountCode
- Add 2 canBeAppliedTo override tests (free ticket guard bypass)
- Add 4 auto_apply tests for existing email-linked types (Member/Speaker promo/discount)
- Fix vacuous testWithPromoCodeAudienceNoPromoCodeNotReturned (now asserts on real data)
- Add 3 serializer tests (auto_apply, remaining_quantity_per_account, email-linked type)
- Rename misleading test to testWithPromoCodeAudienceLivePromoCodeReturned
- Add 5 discovery endpoint integration tests in OAuth2SummitPromoCodesApiTest
- Add 3 checkout enforcement test stubs (2 need order pipeline harness, 1 blocked by D4)
- Mark all 9 review follow-ups complete in SDS doc

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds domain-authorized promo/discount code types with email-domain validation and per-account limits, adds an auto-apply flag across promo types, exposes a discover endpoint for authenticated members, enforces promo-code-only ticket purchases and per-account limits during reservation, and updates serializers, repository logic, migrations, and tests.

Changes

Cohort / File(s) Summary
Domain-Authorized Models & Traits
app/Models/.../PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php, app/Models/.../PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php, app/Models/.../PromoCodes/DomainAuthorizedPromoCodeTrait.php, app/Models/.../PromoCodes/IDomainAuthorizedPromoCode.php, app/Models/.../PromoCodes/AutoApplyPromoCodeTrait.php
New domain-authorized promo/discount entities, traits for allowed email domains and auto-apply, marker interface, per-account remaining-quantity transient, and overrides for ticket-type/rule logic.
Apply Auto-Apply to Existing Models
app/Models/.../PromoCodes/MemberSummitRegistrationPromoCode.php, app/Models/.../PromoCodes/MemberSummitRegistrationDiscountCode.php, app/Models/.../PromoCodes/SpeakerSummitRegistrationPromoCode.php, app/Models/.../PromoCodes/SpeakerSummitRegistrationDiscountCode.php
Added AutoApplyPromoCodeTrait and auto_apply metadata to member/speaker promo and discount types.
Serializers & Registry
app/ModelSerializers/.../DomainAuthorizedSummitRegistrationPromoCodeSerializer.php, app/ModelSerializers/.../DomainAuthorizedSummitRegistrationDiscountCodeSerializer.php, app/ModelSerializers/.../MemberSummitRegistrationPromoCodeSerializer.php, app/ModelSerializers/.../MemberSummitRegistrationDiscountCodeSerializer.php, app/ModelSerializers/.../SpeakerSummitRegistrationPromoCodeSerializer.php, app/ModelSerializers/.../SpeakerSummitRegistrationDiscountCodeSerializer.php, app/ModelSerializers/SerializerRegistry.php
Added serializers for domain-authorized types, added auto_apply mapping to existing serializers, ensure allowed_ticket_types expansion handling and include remaining_quantity_per_account; registry entries added.
API: Controller, Route, Seeder
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php, routes/api_v1.php, database/seeders/ApiEndpointsSeeder.php
New OAuth2 discover endpoint GET /api/v1/summits/{id}/promo-codes/all/discover with controller action and seeded API entry.
Service & Repository Discovery
app/Services/Model/ISummitPromoCodeService.php, app/Services/Model/Imp/SummitPromoCodeService.php, app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php, app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
Added discoverPromoCodes service method; repository additions getDiscoverableByEmailForSummit() and getTicketCountByMemberAndPromoCode() to load and filter discoverable codes by email/domain, liveliness, and per-account usage.
Order Reservation & Saga Tasks
app/Services/Model/Imp/SummitOrderService.php
Reordered saga tasks; PreProcessReservationTask now rejects promo-code-only ticket types when no promo code present; ApplyPromoCodeTask enforces per-account limits for domain-authorized codes; constructors updated and ReserveOrderTask::undo() implemented.
Ticket Types & Strategy
app/Models/Foundation/Summit/Registration/SummitTicketType.php, app/Models/Foundation/Summit/Registration/PromoCodes/Strategies/RegularPromoCodeTicketTypesStrategy.php, app/Models/Foundation/Summit/Summit.php
Added Audience_With_Promo_Code and isPromoCodeOnly(); strategy now includes promo-code-only types when a live promo is present; Summit::canBuyRegistrationTicketByType short-circuits for promo-code audience.
Factory, Constants, Validation Rule
app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php, app/Models/Foundation/Summit/Registration/PromoCodes/PromoCodesConstants.php, app/Http/Controllers/.../PromoCodesValidationRulesFactory.php, app/Rules/AllowedEmailDomainsArray.php
Factory builds/populates domain-authorized types and auto_apply; constants updated to include new class names; validation rules accept auto_apply and new AllowedEmailDomainsArray rule added.
Doctrine Mapping & Migration
app/Models/Foundation/Summit/Registration/PromoCodes/SummitRegistrationPromoCode.php, database/migrations/model/Version20260401150000.php
STI discriminator extended for new subtypes; migration adds joined subtype tables, AllowedEmailDomains, QuantityPerAccount, AutoApply columns, WithPromoCode audience value, and updates to existing joined tables.
Tests
tests/Unit/Services/DomainAuthorizedPromoCodeTest.php, tests/Unit/Services/PreProcessReservationTaskTest.php, tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php, tests/oauth2/OAuth2SummitPromoCodesApiTest.php, tests/Unit/Services/SagaCompensationTest.php
Extensive unit and integration tests covering domain matching, per-account limits, discovery filtering, serializer outputs, reservation enforcement, saga compensation, and API behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Authenticated Client
    participant Controller as OAuth2 Controller
    participant Service as PromoCodeService
    participant Repository as PromoCodeRepo
    participant DB as Database

    Client->>Controller: GET /promo-codes/all/discover
    Controller->>Controller: Load Summit & Current User
    Controller->>Service: discoverPromoCodes(summit, member)
    Service->>Repository: getDiscoverableByEmailForSummit(summit, email)
    Repository->>DB: Query promo/discount codes by email/domain/subtype
    DB-->>Repository: Return matching codes
    Repository-->>Service: codes[]
    loop Filter Each Code
        Service->>Service: Check isLive() & hasQuantityAvailable()
        Service->>Repository: getTicketCountByMemberAndPromoCode() (for domain-authorized)
        Repository-->>Service: usedCount
        Service->>Service: Enforce per-account limit & set remaining_quantity_per_account
    end
    Service-->>Controller: Filtered codes[]
    Controller->>Controller: Serialize each code (expand/fields/relations)
    Controller-->>Client: JSON {data: [], total, per_page, current_page, last_page}
Loading
sequenceDiagram
    participant Order as OrderService
    participant PreProcess as PreProcessReservationTask
    participant Reserve as ReserveOrderTask
    participant Apply as ApplyPromoCodeTask
    participant Repo as PromoCodeRepo
    participant Promo as PromoCode Model

    Order->>PreProcess: run() - validate tickets
    PreProcess->>PreProcess: Reject if isPromoCodeOnly() and no promo_code
    PreProcess-->>Order: reservations state
    Order->>Reserve: run() - reserve tickets
    Reserve-->>Order: order & tickets created
    Order->>Apply: run() - apply promo code
    Apply->>Repo: Load promo code
    Repo-->>Apply: PromoCode
    Apply->>Promo: If IDomainAuthorizedPromoCode?
    alt Domain-Authorized
        Apply->>Repo: getTicketCountByMemberAndPromoCode()
        Repo-->>Apply: usedCount
        Apply->>Promo: Check usedCount < getQuantityPerAccount()
        alt Over Limit
            Apply-->>Order: ValidationException
        else Under Limit
            Apply->>Promo: Apply redemption
            Apply-->>Order: Success
        end
    else Regular Code
        Apply->>Promo: Apply redemption
        Apply-->>Order: Success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • smarcet
  • romanetar
  • martinquiroga-exo

Poem

🐇 I hopped through domains and codes so bright,

I matched emails by day and by night,
auto-apply carrots rolled into place,
discover lit up each eager face,
a rabbit cheers — hop, validate, delight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely summarizes the main change: introducing domain-authorized promo codes for early registration access, which is the primary feature implemented across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/promo-codes-early-registration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@caseylocker caseylocker self-assigned this Apr 9, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

The GET /api/v1/summits/{id}/promo-codes/all/discover route was added in
Task 12 but never seeded into the api_endpoints table. The OAuth2 bearer
token validator middleware rejects any unregistered route with a 400
"API endpoint does not exits" error, causing 5 discover-endpoint integration
tests in OAuth2SummitPromoCodesApiTest to fail.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

The discover endpoint's seeder entry intentionally omits authz_groups per
SDS Task 9 ("any authenticated user with read scope"). The auth.user
middleware requires at least one matching group, so every request fell
through to a 403. Switch to rate.limit:25,1 to match the adjacent
pre-validate-promo-code route, which has the same "any authenticated user"
profile. OAuth2 bearer auth and scope enforcement are still applied via
the parent 'api' middleware group.

All 5 discover integration tests now pass (verified locally).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

…ted discovery codes

Two review findings on the promo-codes branch.

P1 — `POST /orders` allowed reserving audience=WithPromoCode ticket types
with just a `type_id` and no `promo_code`. `Summit::canBuyRegistrationTicketByType`
unconditionally returns true for that audience, and
`PreProcessReservationTask::run` only validated a promo code when one was
supplied. Add an explicit `isPromoCodeOnly() && empty($promo_code_value)`
guard that throws ValidationException; reuses the existing
`SummitTicketType::isPromoCodeOnly()` helper.

P2 — Promo code discovery endpoint returned globally exhausted finite codes
(`quantity_used >= quantity_available`). The repository filter uses
`isLive()` which is dates-only, and the service layer only enforced the
per-account quota. Add a `hasQuantityAvailable()` short-circuit at the top
of `SummitPromoCodeService::discoverPromoCodes` so discovery matches
`validate()` behavior at checkout.

Regression tests:
- `tests/Unit/Services/PreProcessReservationTaskTest.php` — pure PHPUnit
  unit tests for the WithPromoCode guard (reject + non-overreach).
- `tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php` — pure
  PHPUnit unit tests for the global exhaustion filter (reject, healthy
  passes, mixed batch).
- `tests/oauth2/OAuth2SummitPromoCodesApiTest.php` —
  `testDiscoverExcludesGloballyExhaustedCodes`, sibling to the existing
  per-account exhaustion integration test.

Mutation-verified: temporarily reverted both fixes, confirmed that 3 of 5
new unit tests fail as expected, then restored.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Follow-up to b87cefd addressing Codex review suggestions #2 and #4.

PreProcessReservationTaskTest: add two mixed-payload tests exercising the
per-ticket guard in heterogeneous reservations (promo-only +
Audience_All), both orderings. The original tests only covered
single-ticket payloads.

- testRejectsMixedPayloadWithPromoCodeOnlyFirst — guard fires on first iter.
- testRejectsMixedPayloadWithPromoCodeOnlySecond — guard fires after prior
  aggregation; proves the exception short-circuits cleanly.

SummitPromoCodeServiceDiscoveryTest: add an infinite-code overreach test
that pins the `quantity_available == 0` semantics — `hasQuantityAvailable()`
short-circuits to true for infinite codes, so the exhaustion guard must
not drop them.

- testDiscoverReturnsInfiniteDomainAuthorizedCode.

Mutation-verified: reverting the production fixes causes the 3 reject
tests to fail while the infinite-code and healthy-code tests still pass,
as expected for overreach guards.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Serializer unit tests (testSerializerAutoApplyField,
testSerializerRemainingQuantityPerAccount, testSerializerAutoApplyEmailLinkedType)
were failing because bare model instances lacked a Summit association, causing
getSummitId() to call getId() on null. Added buildMockSummitForSerializer()
helper and setSummit() calls in all three tests. Updated D3 deviation status
to RESOLVED — AllowedEmailDomainsArray custom rule was already implemented.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

@caseylocker caseylocker requested review from JpMaxMan and smarcet April 10, 2026 14:49
@caseylocker caseylocker marked this pull request as ready for review April 10, 2026 14:50
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Services/Model/Imp/SummitOrderService.php (2)

284-296: ⚠️ Potential issue | 🔴 Critical

The saga state is dropped before ApplyPromoCodeTask.

After this reorder, ApplyPromoCodeTask::run() no longer receives promo_codes_usage because ReserveOrderTask::run() replaces the state with ['order' => $order]. That means promo-code redemption/enforcement now runs with missing input, and any failure happens after the order was already persisted while ReserveOrderTask::undo() is still empty.

🛠️ Minimal state fix
-            return ['order' => $order];
+            return array_merge($this->formerState, ['order' => $order]);

Also applies to: 667-674

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitOrderService.php` around lines 284 - 296,
ReserveOrderTask::run() currently replaces the saga state (losing keys like
promo_codes_usage) so ApplyPromoCodeTask::run() no longer receives required
promo data; fix by preserving/merging state instead of overwriting (ensure
ReserveOrderTask::run() returns array_merge($existingState, ['order' => $order])
or similar), or alternatively move the ApplyPromoCodeTask invocation to run
before ReserveOrderTask so promo redemption happens prior to persisting the
order; also apply the same fix at the other occurrence referenced (lines
~667-674) and ensure undo() implementations handle partial persistence.

728-742: ⚠️ Potential issue | 🟠 Major

Guest/public reservations can bypass QuantityPerAccount.

This check is skipped whenever the saga was built with a null $owner, but ReserveOrderTask can later attach the order to an existing member via owner_email. In that flow, the per-account limit is never enforced even though the order now belongs to an account. Resolve the owner from the reserved order (or by owner_email) before counting usage.

Also applies to: 791-809

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitOrderService.php` around lines 728 - 742, The
constructor currently stores a nullable $owner causing per-account checks
(QuantityPerAccount) to be skipped; update the reserve flow so that before
enforcing QuantityPerAccount (in ReserveOrderTask and the methods around the
QuantityPerAccount check referenced at lines ~791-809) you resolve the actual
owner from the reserved order (or by owner_email) instead of relying on the
initial $this->owner being non-null; specifically, locate the QuantityPerAccount
enforcement code and change it to obtain the owner via the reserved Order object
(or lookup by owner_email) and then perform the per-account usage count against
that resolved Member before allowing the reservation to proceed.
🧹 Nitpick comments (10)
app/Models/Foundation/Summit/Summit.php (1)

5555-5568: Keep promo-code visibility separate from purchase authorization.

Returning true here means models\summit\Summit::canBuyRegistrationTicketByType() no longer answers “can this email buy this ticket type?” for models\summit\SummitTicketType::Audience_With_Promo_Code; it only says the ticket is not invitation-gated. That makes the helper easy to reuse incorrectly later and accidentally bypass the promo-code requirement. Consider threading promo-code context into this check, or splitting visibility from authorization into separate helpers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Models/Foundation/Summit/Summit.php` around lines 5555 - 5568, The
current Summit::canBuyRegistrationTicketByType() unconditionally returns true
for SummitTicketType::Audience_With_Promo_Code which conflates visibility with
purchase authorization; instead remove the unconditional return and either (a)
add a promo-context parameter (e.g. $promoCodeProvided or $promoContext) to
canBuyRegistrationTicketByType and check promo validity before granting
purchase, or (b) split responsibilities by creating a separate visibility helper
(e.g. isTicketTypeVisibleToEmail or canViewTicketType) that returns true for
Audience_With_Promo_Code while leaving canBuyRegistrationTicketByType to enforce
promo-code checks; update callers to pass the promo context or use the new
visibility helper as appropriate.
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php (1)

1585-1602: OpenAPI annotation missing fields and relations query parameters.

The implementation at lines 1617-1622 reads fields and relations from the request, but the OpenAPI annotation only documents the expand parameter. Consider adding the missing parameters for complete API documentation.

         parameters: [
             new OA\Parameter(name: "id", in: "path", required: true, schema: new OA\Schema(type: "integer")),
             new OA\Parameter(name: "expand", in: "query", required: false, schema: new OA\Schema(type: "string")),
+            new OA\Parameter(name: "fields", in: "query", required: false, schema: new OA\Schema(type: "string")),
+            new OA\Parameter(name: "relations", in: "query", required: false, schema: new OA\Schema(type: "string")),
         ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php`
around lines 1585 - 1602, The OpenAPI annotation for the controller method (the
OA\Get block above discoverPromoCodesBySummit in
OAuth2SummitPromoCodesApiController) is missing the query parameters used in the
implementation; add two OA\Parameter entries for "fields" and "relations" (both
in="query", required=false, schema type="string") with brief descriptions (e.g.,
"Comma-separated list of fields to include" and "Comma-separated list of
relations to expand") so the documented parameters match the code that reads
fields and relations from the request.
app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php (1)

668-721: Discovery filtering logic is correct but has minor duplication.

The method correctly:

  • Normalizes email to lowercase
  • Uses INSTANCE OF to fetch only discoverable code types
  • Applies type-specific matching (domain vs. owner email)
  • Checks isLive() for all codes

The member and speaker code blocks (lines 702-716) share identical logic. Consider extracting to reduce duplication:

♻️ Optional refactor to reduce duplication
-            // Email-linked types: match by associated member/speaker email
-            if ($code instanceof MemberSummitRegistrationPromoCode || $code instanceof MemberSummitRegistrationDiscountCode) {
-                $ownerEmail = $code->getOwnerEmail();
-                if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
-                    $results[] = $code;
-                }
-                continue;
-            }
-
-            if ($code instanceof SpeakerSummitRegistrationPromoCode || $code instanceof SpeakerSummitRegistrationDiscountCode) {
-                $ownerEmail = $code->getOwnerEmail();
-                if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
-                    $results[] = $code;
-                }
-                continue;
-            }
+            // Email-linked types: match by associated member/speaker email
+            if (method_exists($code, 'getOwnerEmail')) {
+                $ownerEmail = $code->getOwnerEmail();
+                if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
+                    $results[] = $code;
+                }
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`
around lines 668 - 721, The member and speaker owner-email checks inside
getDiscoverableByEmailForSummit are duplicated; extract the shared logic into a
small private helper (e.g., private function isOwnerEmailMatch($code, string
$email): bool) that reads $code->getOwnerEmail(), lowercases and compares to
$email and checks $code->isLive(), then replace the two separate blocks (the
checks against MemberSummitRegistrationPromoCode,
MemberSummitRegistrationDiscountCode and against
SpeakerSummitRegistrationPromoCode, SpeakerSummitRegistrationDiscountCode) with
either a single instanceof-any conditional that calls the helper or two concise
calls to the helper, leaving domain-authorized logic unchanged.
app/Rules/AllowedEmailDomainsArray.php (1)

70-73: trans() used with literal string instead of translation key.

The trans() function expects a translation key, not a literal message. As written, it will return the string as-is. For proper i18n support:

-        return trans('The :attribute must be an array of valid email domain patterns (`@domain.com`, .tld, or [email protected]).');
+        return 'The :attribute must be an array of valid email domain patterns (`@domain.com`, .tld, or [email protected]).';

Or define a proper translation key if i18n is required.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Rules/AllowedEmailDomainsArray.php` around lines 70 - 73, The message()
method in AllowedEmailDomainsArray currently calls trans() with a literal
string; replace it to use a translation key (e.g. use
__('validation.allowed_email_domains_array') or
trans('validation.allowed_email_domains_array')) in the message() method and add
the corresponding key/value in your language files
(resources/lang/{locale}/validation.php) so i18n works correctly; reference:
AllowedEmailDomainsArray::message().
app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php (1)

21-48: Consider adding expand_mappings for allowed_ticket_types.

The promo code model includes an allowed_ticket_types relationship (inherited from SummitRegistrationPromoCode), and the parallel DomainAuthorizedSummitRegistrationDiscountCodeSerializer includes expand_mappings for this field. For API consistency, add the same mapping to the promo code serializer:

+    protected static $expand_mappings = [
+        'allowed_ticket_types' => [
+            'type' => \Libs\ModelSerializers\Many2OneExpandSerializer::class,
+            'getter' => 'getAllowedTicketTypes',
+        ],
+    ];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php`
around lines 21 - 48, The serializer
DomainAuthorizedSummitRegistrationPromoCodeSerializer is missing an
expand_mappings entry for the inherited allowed_ticket_types relation; add a
protected static $expand_mappings property (matching the pattern used in
DomainAuthorizedSummitRegistrationDiscountCodeSerializer) that maps
'allowed_ticket_types' to the appropriate expansion key/serializer (e.g., the
allowed_ticket_types expansion that returns the ticket types collection using
the same serializer used elsewhere for ticket types), so API consumers can
expand allowed_ticket_types when calling the serialize method.
app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php (1)

122-134: Add PHPDoc documentation for $company parameter — it's part of the interface contract across all promo code implementations.

The $company parameter is unused in this method but is retained for consistency with the base class signature SummitRegistrationPromoCode::checkSubject(), which is polymorphically implemented across all promo code trait variants (Member, Speaker, Speakers, Sponsor, DomainAuthorized). Add a brief PHPDoc note explaining its retention for interface compatibility:

/**
 * `@param` string $email
 * `@param` null|string $company Retained for interface compatibility; not used in domain-authorized validation
 * `@return` bool
 * `@throws` ValidationException
 */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php`
around lines 122 - 134, Add a PHPDoc block to the
DomainAuthorizedPromoCodeTrait::checkSubject method documenting the unused
$company parameter for interface compatibility; update the docblock to include
`@param` string $email, `@param` null|string $company Retained for interface
compatibility; not used in domain-authorized validation, `@return` bool and
`@throws` ValidationException so the method signature matches the
SummitRegistrationPromoCode::checkSubject contract and clarifies why $company is
intentionally unused.
app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php (1)

51-58: This override doesn't implement the documented audience bypass.

Right now it only forwards to parent::addAllowedTicketType(), so the “regardless of audience value” behavior in the comment is not actually expressed here. Either remove the override/comment or put the intended bypass logic in this method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php`
around lines 51 - 58, The override addAllowedTicketType in
DomainAuthorizedSummitRegistrationPromoCode currently just calls
parent::addAllowedTicketType and does not implement the claimed "regardless of
audience" behavior; either remove this override and its comment, or implement
the bypass by directly adding the SummitTicketType to this promo's allowed
ticket collection without performing the parent's audience check (e.g., perform
the collection add/update inside addAllowedTicketType on
DomainAuthorizedSummitRegistrationPromoCode rather than calling
parent::addAllowedTicketType). Ensure you update or remove the doc comment to
match the actual behavior.
tests/oauth2/OAuth2SummitPromoCodesApiTest.php (1)

1085-1116: These skipped checkout tests won't catch regressions.

Right now the new quantity-per-account checkout path has no protection from this file because all three enforcement tests are permanently skipped. If the full OAuth/saga harness is too heavy, a narrower test around ApplyPromoCodeTask::run() would still cover the new guard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/oauth2/OAuth2SummitPromoCodesApiTest.php` around lines 1085 - 1116, The
three checkout tests in OAuth2SummitPromoCodesApiTest.php are skipped and leave
the new quantity-per-account enforcement untested; add focused unit tests that
call ApplyPromoCodeTask::run() directly (instead of the full saga) to cover
over-limit, under-limit, and basic concurrency behavior: create tests that
construct an ApplyPromoCodeTask with mocked dependencies (e.g., promo
repository, order repository/count query, and any lock/persistence layer), stub
the member's existing redemptions count to simulate over-limit and under-limit
scenarios and assert the task throws the expected rejection or completes
successfully, and for concurrency simulate the reservation/count behavior via
mocks to assert the pessimistic-lock path; place these tests in
OAuth2SummitPromoCodesApiTest.php replacing the skipped methods
(testCheckoutRejectsOverLimitQuantityPerAccount,
testCheckoutSucceedsUnderLimitQuantityPerAccount,
testCheckoutConcurrentEnforcement) so ApplyPromoCodeTask::run() is directly
exercised.
tests/Unit/Services/DomainAuthorizedPromoCodeTest.php (1)

291-305: Vacuously true assertion reduces test value.

Line 304 asserts that id 99 is not in the result, but no ticket type with id 99 was created in this test. The assertion will always pass regardless of the actual filtering behavior.

To strengthen this test, either:

  1. Remove the misleading assertion about id 99 (the test still proves the strategy returns results via the id 30 check), or
  2. Add a WithPromoCode ticket type to the promo code mock and verify the strategy correctly excludes it when no promo code is provided.
♻️ Suggested fix — remove misleading assertion
         $ids = array_map(fn($tt) => $tt->getId(), $result);
         // Audience_All type IS returned (non-vacuous: proves the strategy produces results)
         $this->assertContains(30, $ids, 'Audience_All ticket type should be returned without a promo code');
-        // WithPromoCode type (id 99) is NOT returned — it only lives in promo_code->getAllowedTicketTypes()
-        $this->assertNotContains(99, $ids, 'WithPromoCode ticket types should not be returned without a promo code');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Unit/Services/DomainAuthorizedPromoCodeTest.php` around lines 291 -
305, The assertion is vacuous because no ticket type with id 99 exists in this
test; update the testWithPromoCodeAudienceNoPromoCodeNotReturned to actually
create a "WithPromoCode" ticket type (e.g., via buildMockTicketType(99,
SummitTicketType::Audience_Promo) or similar) and attach it only to a mocked
promo code's getAllowedTicketTypes() so that
RegularPromoCodeTicketTypesStrategy($summit, $member, null)->getTicketTypes()
has both id 30 and id 99 available in the source data and you can assert that 30
is returned while 99 is not; alternatively, if you prefer the simpler change,
remove the misleading assertNotContains(99, $ids) line entirely to avoid the
vacuous check.
app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php (1)

53-60: Remove this no-op override or clarify intent with accurate documentation.

The parent SummitRegistrationPromoCode::addAllowedTicketType() contains no audience-based restrictions—it simply adds the ticket type to the collection. This override merely delegates to the parent without any custom logic, making it redundant. The docblock's claim that it "allows any ticket type regardless of audience value" is misleading, as the parent already permits any ticket type. Either remove the override or, if it serves a semantic purpose in the inheritance hierarchy, update the docblock to accurately reflect that.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php`
around lines 53 - 60, The addAllowedTicketType method in
DomainAuthorizedSummitRegistrationDiscountCode is a redundant no-op that only
calls parent::addAllowedTicketType (the parent SummitRegistrationPromoCode
already permits any ticket type); either remove this override entirely to avoid
dead code, or if you want an explicit semantic override, keep the method but
replace the misleading docblock with a concise note like "Intentional
passthrough to parent to signal inherited behavior" and ensure the method body
remains a direct parent call (referencing
DomainAuthorizedSummitRegistrationDiscountCode::addAllowedTicketType and
SummitRegistrationPromoCode::addAllowedTicketType).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php`:
- Around line 295-296: Normalize the allowed_email_domains input to an array
before calling the typed setter setAllowedEmailDomains: if
$data['allowed_email_domains'] is a string, split on commas (or another
delimiter used by CSV), trim each entry and remove empty values so you always
pass an array to SummitPromoCodeFactory::setAllowedEmailDomains; apply the same
normalization pattern for the other CSV-driven fields that expect arrays (the
other typed setters in this factory, e.g., setAllowedTicketTypes /
setTicketTypesRules or any similar setter calls around the nearby block) so
string CSV values don't throw and get silently skipped.
- Around line 201-202: Replace uses of boolval(...) for the auto_apply field
with filter_var($data['auto_apply'], FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE) so string values like "false" are parsed correctly;
specifically update every branch that calls
$promo_code->setAutoApply(boolval($data['auto_apply'])) (all occurrences
handling auto_apply in SummitPromoCodeFactory.php) to first compute the boolean
via filter_var and then pass that result to $promo_code->setAutoApply, applying
the same change to each auto_apply branch mentioned (the multiple setAutoApply
call sites).

In
`@app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCodeSerializer.php`:
- Line 98: Replace the hardcoded null assignment to
$values['remaining_quantity_per_account'] with the actual value from the
domain-authorized discount model (e.g. call the model's getter such as
getRemainingQuantityPerAccount() on the serialized object or
$domainAuthorizedDiscount/$this->object) and only fall back to null if that
getter returns an unset/empty value; update
MemberSummitRegistrationDiscountCodeSerializer to read and serialize the real
remaining-per-account value instead of forcing null.

In `@app/Rules/AllowedEmailDomainsArray.php`:
- Around line 46-58: The .tld branch in AllowedEmailDomainsArray (the elseif
that checks str_starts_with($element, '.')) uses a regex that only allows a
single label after the leading dot; update that regex to accept multi-level
suffixes by matching a leading dot followed by one label and then zero or more
additional ".label" segments (i.e., allow repeated dot-separated labels) so
patterns like ".co.uk" or ".ac.jp" are accepted; replace the existing /^\.\w+$/
pattern in that branch accordingly.

In `@database/migrations/model/Version20260401150000.php`:
- Around line 99-117: In the down() migration, before reverting the ClassName
ENUM on SummitRegistrationPromoCode, remove or update any rows that reference
the domain-authorized discriminators so the ALTER TABLE won't fail;
specifically, after dropping the joined tables
DomainAuthorizedSummitRegistrationPromoCode and
DomainAuthorizedSummitRegistrationDiscountCode, delete (or UPDATE ClassName to a
valid value) any SummitRegistrationPromoCode rows whose ClassName equals the
domain-authorized values (e.g., the discriminator values introduced for
domain-authorized promo/discount codes) and only then run the ALTER TABLE ...
MODIFY ClassName ENUM(...) DEFAULT 'SummitRegistrationPromoCode' to narrow the
enum safely.

In `@routes/api_v1.php`:
- Around line 1953-1956: The discover route is currently rate-limited but
unauthenticated; update the route definition for the 'all/discover' endpoint to
include the authentication middleware so only authenticated users can call
OAuth2SummitPromoCodesApiController@discover. Modify the middleware array on the
Route::get for 'discover' to add 'auth.user' alongside 'rate.limit:25,1' (e.g.,
['middleware' => ['auth.user', 'rate.limit:25,1']]) so the route enforces
authentication at the routing layer before controller execution.

---

Outside diff comments:
In `@app/Services/Model/Imp/SummitOrderService.php`:
- Around line 284-296: ReserveOrderTask::run() currently replaces the saga state
(losing keys like promo_codes_usage) so ApplyPromoCodeTask::run() no longer
receives required promo data; fix by preserving/merging state instead of
overwriting (ensure ReserveOrderTask::run() returns array_merge($existingState,
['order' => $order]) or similar), or alternatively move the ApplyPromoCodeTask
invocation to run before ReserveOrderTask so promo redemption happens prior to
persisting the order; also apply the same fix at the other occurrence referenced
(lines ~667-674) and ensure undo() implementations handle partial persistence.
- Around line 728-742: The constructor currently stores a nullable $owner
causing per-account checks (QuantityPerAccount) to be skipped; update the
reserve flow so that before enforcing QuantityPerAccount (in ReserveOrderTask
and the methods around the QuantityPerAccount check referenced at lines
~791-809) you resolve the actual owner from the reserved order (or by
owner_email) instead of relying on the initial $this->owner being non-null;
specifically, locate the QuantityPerAccount enforcement code and change it to
obtain the owner via the reserved Order object (or lookup by owner_email) and
then perform the per-account usage count against that resolved Member before
allowing the reservation to proceed.

---

Nitpick comments:
In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php`:
- Around line 1585-1602: The OpenAPI annotation for the controller method (the
OA\Get block above discoverPromoCodesBySummit in
OAuth2SummitPromoCodesApiController) is missing the query parameters used in the
implementation; add two OA\Parameter entries for "fields" and "relations" (both
in="query", required=false, schema type="string") with brief descriptions (e.g.,
"Comma-separated list of fields to include" and "Comma-separated list of
relations to expand") so the documented parameters match the code that reads
fields and relations from the request.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php`:
- Around line 122-134: Add a PHPDoc block to the
DomainAuthorizedPromoCodeTrait::checkSubject method documenting the unused
$company parameter for interface compatibility; update the docblock to include
`@param` string $email, `@param` null|string $company Retained for interface
compatibility; not used in domain-authorized validation, `@return` bool and
`@throws` ValidationException so the method signature matches the
SummitRegistrationPromoCode::checkSubject contract and clarifies why $company is
intentionally unused.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php`:
- Around line 53-60: The addAllowedTicketType method in
DomainAuthorizedSummitRegistrationDiscountCode is a redundant no-op that only
calls parent::addAllowedTicketType (the parent SummitRegistrationPromoCode
already permits any ticket type); either remove this override entirely to avoid
dead code, or if you want an explicit semantic override, keep the method but
replace the misleading docblock with a concise note like "Intentional
passthrough to parent to signal inherited behavior" and ensure the method body
remains a direct parent call (referencing
DomainAuthorizedSummitRegistrationDiscountCode::addAllowedTicketType and
SummitRegistrationPromoCode::addAllowedTicketType).

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php`:
- Around line 51-58: The override addAllowedTicketType in
DomainAuthorizedSummitRegistrationPromoCode currently just calls
parent::addAllowedTicketType and does not implement the claimed "regardless of
audience" behavior; either remove this override and its comment, or implement
the bypass by directly adding the SummitTicketType to this promo's allowed
ticket collection without performing the parent's audience check (e.g., perform
the collection add/update inside addAllowedTicketType on
DomainAuthorizedSummitRegistrationPromoCode rather than calling
parent::addAllowedTicketType). Ensure you update or remove the doc comment to
match the actual behavior.

In `@app/Models/Foundation/Summit/Summit.php`:
- Around line 5555-5568: The current Summit::canBuyRegistrationTicketByType()
unconditionally returns true for SummitTicketType::Audience_With_Promo_Code
which conflates visibility with purchase authorization; instead remove the
unconditional return and either (a) add a promo-context parameter (e.g.
$promoCodeProvided or $promoContext) to canBuyRegistrationTicketByType and check
promo validity before granting purchase, or (b) split responsibilities by
creating a separate visibility helper (e.g. isTicketTypeVisibleToEmail or
canViewTicketType) that returns true for Audience_With_Promo_Code while leaving
canBuyRegistrationTicketByType to enforce promo-code checks; update callers to
pass the promo context or use the new visibility helper as appropriate.

In
`@app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php`:
- Around line 21-48: The serializer
DomainAuthorizedSummitRegistrationPromoCodeSerializer is missing an
expand_mappings entry for the inherited allowed_ticket_types relation; add a
protected static $expand_mappings property (matching the pattern used in
DomainAuthorizedSummitRegistrationDiscountCodeSerializer) that maps
'allowed_ticket_types' to the appropriate expansion key/serializer (e.g., the
allowed_ticket_types expansion that returns the ticket types collection using
the same serializer used elsewhere for ticket types), so API consumers can
expand allowed_ticket_types when calling the serialize method.

In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`:
- Around line 668-721: The member and speaker owner-email checks inside
getDiscoverableByEmailForSummit are duplicated; extract the shared logic into a
small private helper (e.g., private function isOwnerEmailMatch($code, string
$email): bool) that reads $code->getOwnerEmail(), lowercases and compares to
$email and checks $code->isLive(), then replace the two separate blocks (the
checks against MemberSummitRegistrationPromoCode,
MemberSummitRegistrationDiscountCode and against
SpeakerSummitRegistrationPromoCode, SpeakerSummitRegistrationDiscountCode) with
either a single instanceof-any conditional that calls the helper or two concise
calls to the helper, leaving domain-authorized logic unchanged.

In `@app/Rules/AllowedEmailDomainsArray.php`:
- Around line 70-73: The message() method in AllowedEmailDomainsArray currently
calls trans() with a literal string; replace it to use a translation key (e.g.
use __('validation.allowed_email_domains_array') or
trans('validation.allowed_email_domains_array')) in the message() method and add
the corresponding key/value in your language files
(resources/lang/{locale}/validation.php) so i18n works correctly; reference:
AllowedEmailDomainsArray::message().

In `@tests/oauth2/OAuth2SummitPromoCodesApiTest.php`:
- Around line 1085-1116: The three checkout tests in
OAuth2SummitPromoCodesApiTest.php are skipped and leave the new
quantity-per-account enforcement untested; add focused unit tests that call
ApplyPromoCodeTask::run() directly (instead of the full saga) to cover
over-limit, under-limit, and basic concurrency behavior: create tests that
construct an ApplyPromoCodeTask with mocked dependencies (e.g., promo
repository, order repository/count query, and any lock/persistence layer), stub
the member's existing redemptions count to simulate over-limit and under-limit
scenarios and assert the task throws the expected rejection or completes
successfully, and for concurrency simulate the reservation/count behavior via
mocks to assert the pessimistic-lock path; place these tests in
OAuth2SummitPromoCodesApiTest.php replacing the skipped methods
(testCheckoutRejectsOverLimitQuantityPerAccount,
testCheckoutSucceedsUnderLimitQuantityPerAccount,
testCheckoutConcurrentEnforcement) so ApplyPromoCodeTask::run() is directly
exercised.

In `@tests/Unit/Services/DomainAuthorizedPromoCodeTest.php`:
- Around line 291-305: The assertion is vacuous because no ticket type with id
99 exists in this test; update the
testWithPromoCodeAudienceNoPromoCodeNotReturned to actually create a
"WithPromoCode" ticket type (e.g., via buildMockTicketType(99,
SummitTicketType::Audience_Promo) or similar) and attach it only to a mocked
promo code's getAllowedTicketTypes() so that
RegularPromoCodeTicketTypesStrategy($summit, $member, null)->getTicketTypes()
has both id 30 and id 99 available in the source data and you can assert that 30
is returned while 99 is not; alternatively, if you prefer the simpler change,
remove the misleading assertNotContains(99, $ids) line entirely to avoid the
vacuous check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e384ac84-d886-48f3-8deb-ae6c4bca21ae

📥 Commits

Reviewing files that changed from the base of the PR and between 906a7a5 and 19e5f53.

📒 Files selected for processing (38)
  • app/Http/Controllers/Apis/Protected/Summit/Factories/Registration/PromoCodesValidationRulesFactory.php
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php
  • app/ModelSerializers/SerializerRegistry.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationPromoCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php
  • app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/AutoApplyPromoCodeTrait.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/MemberSummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/PromoCodesConstants.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/Strategies/RegularPromoCodeTicketTypesStrategy.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/SummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/SummitTicketType.php
  • app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php
  • app/Models/Foundation/Summit/Summit.php
  • app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
  • app/Rules/AllowedEmailDomainsArray.php
  • app/Services/Model/ISummitPromoCodeService.php
  • app/Services/Model/Imp/SummitOrderService.php
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • database/migrations/model/Version20260401150000.php
  • database/seeders/ApiEndpointsSeeder.php
  • doc/promo-codes-for-early-registration-access.md
  • routes/api_v1.php
  • tests/Unit/Services/DomainAuthorizedPromoCodeTest.php
  • tests/Unit/Services/PreProcessReservationTaskTest.php
  • tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php
  • tests/oauth2/OAuth2SummitPromoCodesApiTest.php

Comment thread app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php
Comment thread app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php
Comment thread app/Rules/AllowedEmailDomainsArray.php
Comment thread database/migrations/model/Version20260401150000.php Outdated
Comment thread routes/api_v1.php
… migration rollback

CodeRabbit flagged 6 issues on PR #525. After independent validation (Codex),
2 were confirmed as real bugs, 2 were false positives, and 2 were
informational/misframed.

Fixed (validated as real):
- **CSV import TypeError:** `allowed_email_domains` was not exploded from its
  pipe-delimited CSV string before reaching `setAllowedEmailDomains(array)`,
  causing a TypeError on domain-authorized code import. Added the same
  `explode('|', ...)` normalization used by all other CSV list fields in both
  the add and update import paths.
- **Migration down() failure:** Dropping the joined domain-authorized tables
  did not remove orphaned base-table rows, so narrowing the ClassName ENUM
  would fail if any domain-authorized promo codes existed. Added a DELETE
  statement before the ALTER TABLE.

Dismissed (validated as false positives):
- `remaining_quantity_per_account = null` in MemberDiscountCode serializer is
  correct — Member types do not have per-account quantity.
- Discover route already has OAuth2 auth via the `api` middleware group and
  an explicit controller-level null-member guard. Adding `auth.user` would
  break it (requires authz_groups, intentionally removed in 138c1f8).

Deferred:
- `boolval("false")` pattern is pre-existing across the factory (not
  introduced by this PR); warrants a separate cleanup.
- Multi-level TLD validation regex (`.co.uk`) is an enhancement, not a bug
  in the current domain-matching logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/Services/Model/Imp/SummitPromoCodeService.php (1)

1031-1054: Batch per-code usage lookups for discovery.

getDiscoverableByEmailForSummit() loads the candidate codes, and then each domain-authorized result triggers another getTicketCountByMemberAndPromoCode() call. On a summit with many matching codes, this becomes a 1+N query pattern on the registration hot path. Consider a repository method that returns usage counts keyed by promo-code ID for the member.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitPromoCodeService.php` around lines 1031 - 1054,
The loop in SummitPromoCodeService uses getDiscoverableByEmailForSummit() then
calls getTicketCountByMemberAndPromoCode() per code, causing 1+N queries; add a
repository method such as getTicketCountsByMemberAndPromoCodes($member, array
$promoCodeIds) that returns a map promoCodeId=>usedCount, call it once after
retrieving $codes, then in the foreach over $codes (same loop referencing
IDomainAuthorizedPromoCode, getQuantityPerAccount,
setRemainingQuantityPerAccount) read the used count from that map to decide
exhaustion and set remaining per-account quantity instead of making per-code
repository calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 645-647: The CSV import currently uses isset() then explode('|',
$row['allowed_email_domains']) which yields [''] for blank cells and leaves
whitespace—update both occurrences handling allowed_email_domains in
SummitPromoCodeService.php (the two blocks around the shown diff and the block
at the other path mentioned) to: after exploding by '|' trim each element and
remove any empty strings (e.g., array_map('trim', ...) followed by
array_filter(...) or equivalent) so the resulting $row['allowed_email_domains']
is an array of non-empty, trimmed domains or omitted if none remain.

In `@database/migrations/model/Version20260401150000.php`:
- Around line 103-107: Replace the hard DELETE that removes base-table rows from
SummitRegistrationPromoCode with a safe UPDATE that remaps the discriminator to
the base ClassName so rows (and their IDs/foreign links) are preserved;
specifically modify the SQL in Version20260401150000.php that targets ClassName
IN
('DomainAuthorizedSummitRegistrationDiscountCode','DomainAuthorizedSummitRegistrationPromoCode')
to run an UPDATE setting ClassName = 'SummitRegistrationPromoCode' (or the
appropriate surviving base type) instead of DELETE, ensuring child table cascade
behavior and external FK integrity remain intact.

---

Nitpick comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 1031-1054: The loop in SummitPromoCodeService uses
getDiscoverableByEmailForSummit() then calls
getTicketCountByMemberAndPromoCode() per code, causing 1+N queries; add a
repository method such as getTicketCountsByMemberAndPromoCodes($member, array
$promoCodeIds) that returns a map promoCodeId=>usedCount, call it once after
retrieving $codes, then in the foreach over $codes (same loop referencing
IDomainAuthorizedPromoCode, getQuantityPerAccount,
setRemainingQuantityPerAccount) read the used count from that map to decide
exhaustion and set remaining per-account quantity instead of making per-code
repository calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 629c3bf1-4fa4-415f-bf98-f0555ef818a6

📥 Commits

Reviewing files that changed from the base of the PR and between 19e5f53 and ae261a7.

📒 Files selected for processing (2)
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • database/migrations/model/Version20260401150000.php

Comment thread app/Services/Model/Imp/SummitPromoCodeService.php Outdated
Comment thread database/migrations/model/Version20260401150000.php Outdated
public function up(Schema $schema): void
{
// 1. Create DomainAuthorizedSummitRegistrationDiscountCode joined table
$this->addSql("CREATE TABLE DomainAuthorizedSummitRegistrationDiscountCode (
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker dont use this there are already utils to create table please review used patterns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote in b9f2582. Table creation and ADD COLUMN now use the LaravelDoctrine Builder + Table API (matching Version20231208171355 and Version20260407003923). ENUM MODIFY remains raw addSql because Doctrine Schema has no MySQL ENUM support — same pattern as Version20231208172204. Charset aligned with existing promo-code subtype tables (utf8mb3_unicode_ci). Verified with a full down-up-down-up round-trip on the model DB.

public function down(Schema $schema): void
{
// 1. Drop AutoApply columns from existing email-linked subtype tables
$this->addSql("ALTER TABLE SpeakerSummitRegistrationDiscountCode DROP COLUMN AutoApply");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker
Migration down() Ordering issue
MySQL DDL (DROP TABLE) causes an implicit commit. If the migration fails between step 4 (DROP TABLE) and step 4b (UPDATE ClassName), the joined tables are gone but the
discriminator column still points to them. Doctrine will attempt to join non-existent tables for any row with ClassName DomainAuthorized*, crashing all promo code queries for the entire summit.
Suggested fix:
Swap steps 4 and 4b , remap ClassName to base types first, then drop the joined tables.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b9f2582 (same commit as the table-util refactor). The new down() sequence runs the ClassName remap BEFORE the joined-table drops so a mid-rollback failure can't leave the discriminator pointing at dropped tables.

* @param Member $member
* @return SummitRegistrationPromoCode[]
*/
public function discoverPromoCodes(Summit $summit, Member $member): array
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker getDiscoverableByEmailForSummit loads all discoverable promo codes into memory
The DQL query fetches every promo code of 6 types for the entire summit, then filters in PHP by domain/email. For summits with thousands of codes, this is a full table scan hydrated into entities.

If you want to optimize, the cleanest approach is splitting into two queries:

  1. Domain-authorized codes — fetch all for summit, filter in PHP (unavoidable)
  2. Email-linked codes — filter by email in DQL (eliminates the bulk of hydrations)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split exactly as you suggested in 4db6055 + 999caa1. getDiscoverableByEmailForSummit now delegates to two focused methods:

  1. getDomainAuthorizedDiscoverableForSummit — fetches only the 2 DA types for the summit, filters by matchesEmailDomain() in PHP (unavoidable pattern match).
  2. getEmailLinkedDiscoverableForSummit — 4 DQL queries (one per Member/Speaker x Promo/Discount subtype) push the email filter into the WHERE clause, including the MemberPromoCodeTrait owner-fallback (IS NULL OR empty-string) and the PresentationSpeaker member/registration_request two-hop chain.

Both methods add isLive() date filtering in DQL (:now parameter). The facade method merges results, preserving the existing caller contract — discoverPromoCodes and its exhaustion/quota logic are untouched.

*/
public function passes($attribute, $value): bool
{
if (!is_array($value))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker
AllowedEmailDomainsArray validation rejects multi-level TLD suffix patterns
The .tld suffix regex /^.\w+$/ rejects .co.uk, .com.au, .ac.uk — common patterns for UK/AU/international organizations. Admins can't create these patterns via the API, even
though matchesEmailDomain() handles them correctly at runtime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widened in c62c0f7. The regex now matches one-or-more dot-prefixed alphanumeric labels (/^.[a-z0-9]+(?:.[a-z0-9]+)*$/i), so admins can register .co.uk, .com.au, .ac.uk via the API. Still rejects malformed inputs (., ..com, .com., .co..uk, .-edu). Runtime matcher already accepted these — the validator was incorrectly narrower.

* - @domain.com (exact domain match, starts with @)
* - .tld (TLD/suffix match, starts with .)
* - [email protected] (exact email match)
*/
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker
AllowedEmailDomainsArray validation rule — no tests for edge cases like .co.uk, empty array, mixed valid/invalid patterns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests/Unit/Rules/AllowedEmailDomainsArrayTest.php in c62c0f7. 31 cases cover the regression fix (.co.uk, .com.au, .ac.uk), valid/invalid pattern mixes, empty array, non-array inputs, whitespace handling, and message(). Uses doc-comment @dataProvider to match the one existing precedent in the repo.

});
}

public function undo()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker
ReserveOrderTask::undo() compensation , the new implementation detaches tickets and deletes the order but has no dedicated test verifying the cleanup behavior after ApplyPromoCodeTask failure

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered by tests/Unit/Services/SagaCompensationTest.php::testUndoDeletesOrderAndDetachesTicketsFromAttendees (L100). Asserts the order is deleted and each ticket is detached from its attendee owner after a saga abort.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker please review comments

caseylocker and others added 12 commits April 15, 2026 18:31
Addresses smarcet's PR #525 review:
- Replace raw CREATE TABLE / ADD COLUMN with LaravelDoctrine Builder +
  Table fluent API (matches Version20231208171355, Version20260407003923).
- Reorder down(): remap ClassName discriminator before dropping joined
  tables so an interrupted rollback can't leave the enum pointing at
  non-existent tables (DROP TABLE implicit-commit hazard).
- Keep ENUM MODIFY as raw addSql (Doctrine Schema has no MySQL ENUM
  support; matches Version20231208172204).
- Verified with down/up round-trip on local model DB; Codex audit clean.
Addresses smarcet's PR #525 review items #8 and #9:
- Widen the .tld branch regex from /^\.\w+$/ to
  /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/i so admins can register suffixes like
  .co.uk, .com.au, .ac.uk via the API. The runtime matcher in
  DomainAuthorizedPromoCodeTrait::matchesEmailDomain already supported
  these via str_ends_with — the validator was incorrectly narrower than
  the runtime.
- Add tests/Unit/Rules/AllowedEmailDomainsArrayTest covering 31 cases:
  valid single/multi-label TLDs, @Domain, exact email, uppercase,
  trimmed whitespace, mixed valid; plus rejection of non-arrays, empty
  and whitespace-only entries, non-string elements, nested arrays, and
  malformed patterns (., ..com, .com., .co..uk, .-edu, @, @.com, bare
  tokens, one-bad-apple arrays). Uses @dataProvider doc-comment style
  to match the existing precedent (AbstractOAuth2ApiScopesTest).
…layer)

Schema + data layer for the per-member QuantityPerAccount counter that
will fix the TOCTOU race smarcet flagged in PR #525 (and reproduced in
PR #530). This commit is intentionally NOT wired into the order-reserve
saga yet — the table sits unused until the follow-up commit that
modifies PreProcessReservationTask.

- Entity SummitPromoCodeMemberReservation (SilverstripeBaseModel) with
  unique (PromoCodeID, MemberID) and ManyToOne FKs cascading on delete.
- ISummitPromoCodeMemberReservationRepository + Doctrine impl. Readers
  from the reservation path rely on the outer row lock already held on
  the parent promo code (getByValueExclusiveLock) for serialization,
  so the repo does not take its own PESSIMISTIC_WRITE.
- Two migrations, split so CREATE TABLE commits before the backfill
  INSERT runs (Builder defers schema diff to end-of-migration, so
  INSERT-in-same-migration hits "table doesn't exist"):
    Version20260415191521 — create table via Builder/Table API.
    Version20260415191522 — backfill from existing committed tickets
                            (ON DUPLICATE KEY UPDATE for idempotency).
- RepositoriesProvider binding.
- Verified: down/up round-trip on docker MySQL; php -l clean.
Three no-behavior-change safety nits from the step-1 audit:
- Version20260415191522 backfill now uses GREATEST(QtyUsed, VALUES(QtyUsed))
  on the ON DUPLICATE KEY path so a re-run after live saga writes can
  never clobber a newer counter with a stale historical count.
- Version20260415191522.down() is now a documented no-op. The previous
  TRUNCATE would have silently zeroed live counters once step 2 lands.
  Roll back Version20260415191521 if a clean slate is needed.
- ISummitPromoCodeMemberReservationRepository docblock is reworded so
  the 'outer lock' statement is explicit as a CALLER PRECONDITION,
  not something the repository guarantees or enforces.

Migration down/up round-trip re-verified on docker MySQL.

Remaining step-1 concern — undo idempotency of the entity's decrement
helper — is deferred to step 2, where the saga caller is the right
place to guard duplicate undo invocations (mirror the 'redeem' flag
pattern already used by ApplyPromoCodeTask::undo).
…Task

Step 2 of 3 for the TOCTOU fix. Wires SummitPromoCodeMemberReservation
(added in b1f3abe) into the order-reserve saga. The post-facto check
in ApplyPromoCodeTask stays in place as belt-and-suspenders for this
commit; it is removed in step 3.

- PreProcessReservationTask gains two optional collaborators
  (ISummitPromoCodeMemberReservationRepository, ITransactionService)
  plus a new protected reserveMemberQuotas() pass that runs after the
  existing validation. For each IDomainAuthorizedPromoCode in the
  payload with QuantityPerAccount > 0, it opens a short transaction
  that acquires PESSIMISTIC_WRITE on the parent promo code row (via
  the existing getByValueExclusiveLock), upserts the per-member
  counter, and rejects if the new total would exceed the limit.
- The outer row lock is the serialization point: two concurrent sagas
  serialize on the lock, and the second observes the first's
  increment once the first commits.
- undo() walks the reserved list and decrements each counter under
  the same lock. Idempotent via a local $undone flag; best-effort
  (logs and continues on failure so remaining codes still release).
- SagaFactory and SummitOrderService ctors are extended to carry the
  new repo through from Laravel's container; PreProcessReservationTask
  ctor params stay nullable-optional so the existing 2-arg construction
  path in tests and the PrePaid subclass keeps working.

Verified:
  docker exec summit-api vendor/bin/phpunit --filter
    "PreProcessReservationTaskTest|SagaCompensationTest|ApplyPromoCodeTaskQuantityPerAccountTest"
    → 13/13 pass (baseline backward compat)
  php -l clean.

Step 3 (follow-up) will remove the post-facto check in ApplyPromoCodeTask,
update smarcet's PR #530 test mocks to exercise the new reservation
surface, and add dedicated coverage for the pre-reservation path.
Codex audit of ad113d5 caught a real BUG. Saga::run() at
SummitOrderService.php:131-134 only calls markAsRan() AFTER a task's
run() returns. If reserveMemberQuotas() succeeds for code A and then
throws on code B, the exception propagates before PreProcessReservationTask
is in $already_run_tasks, so saga abort() never invokes this task's
undo() — leaking code A's counter increment on the durable reservation
row.

Guard reserveMemberQuotas() with a local try/catch in run() that calls
$this->undo() (idempotent via the $undone flag) before rethrowing, so
any partial progress is released whether or not the saga reaches us.

Found by Codex review, patch as proposed.
  The output is .E. — tests 1 and 3 pass, test 2
    (testDoubleRejection_BothReservedBeforeEitherValidates) fails with:
ValidationException: Promo code DOMAIN-CODE-1 has reached the maximum of 1 tickets per account.
  Task A throws at SummitOrderService.php:843 (the $existingCount > $quantityPerAccount guard)
    (exactly the TOCTOU bug).
The test asserts Task A should succeed (it's a valid first request), but the inflated count
from both orders' tickets being visible causes it to reject.
When the race condition is fixed, this test will start passing.
…ests

Step 3 of 3. The per-member QuantityPerAccount check now lives solely
in PreProcessReservationTask::reserveMemberQuotas (added in ad113d5,
leak-guarded in 77c3059), where it runs atomically under the
PESSIMISTIC_WRITE row lock on the parent promo code, BEFORE
ReserveTicketsTask and ReserveOrderTask commit any tickets.

Changes:

- Remove the belt-and-suspenders post-facto check from
  ApplyPromoCodeTask::run. A comment now points at the pre-reservation
  location and links to smarcet's TOCTOU reproduction for context.
  The old check counted committed tickets and could not distinguish
  concurrent orders' rows — see the race narrative in PR #525.

- Delete tests/Unit/Services/ApplyPromoCodeTaskConcurrencyTest.php.
  smarcet added this in the cherry-picked 2e0ef84 to prove the TOCTOU
  bug against the old check surface (static
  getTicketCountByMemberAndPromoCode). That surface is no longer in
  the write path, so the test targets removed code. The narrative is
  preserved — and extended — in the new file below.

- Delete tests/Unit/Services/ApplyPromoCodeTaskQuantityPerAccountTest.php.
  All six cases validated the exact post-facto check that was just
  removed.

- Add tests/Unit/Services/PreProcessReservationTaskConcurrencyTest.php
  with 6 cases exercising the new surface via reflection on
  reserveMemberQuotas:
    1. First reserve succeeds when no prior row exists (repo `add`
       called with qty_used=1).
    2. Second reserve rejects when the prior row's QtyUsed already
       sits at the limit (the serialized-second-request flow that
       replaces smarcet's TOCTOU reproduction).
    3. Within-limit reserve increments the existing row.
    4. Limit = 0 bypasses reservation entirely (unlimited per account).
    5. Non-IDomainAuthorizedPromoCode codes are skipped (no
       reservation repo calls).
    6. undo() decrements each reserved counter exactly once and is
       idempotent via the $undone guard.

Verified:
  docker exec summit-api composer dump-autoload    # pick up new entity
  docker exec summit-api vendor/bin/phpunit --filter
    "PreProcessReservationTask|SagaCompensationTest|ApplyPromoCodeTask"
    → 13/13 pass (3 PHPUnit deprecations match repo baseline).

Outstanding from smarcet's PR #525 review: #7 (discoverPromoCodes N+1)
is the only remaining item.
Two findings from Codex audit of 999eec1:

1. BUG — ApplyPromoCodeTask's pointer comment referenced
   tests/Unit/Services/ApplyPromoCodeTaskConcurrencyTest, a file
   deleted in the same commit. Repoint at the new location,
   tests/Unit/Services/PreProcessReservationTaskConcurrencyTest.
   (Patch applied by Codex.)

2. CONCERN — coverage parity lost the "single order qty > limit
   with no prior reservation row" case from the deleted
   ApplyPromoCodeTaskQuantityPerAccountTest::testRejectsWhenOrderExceedsLimit.
   Restore that branch via a new PreProcessReservation test:
   testSingleOrderExceedingLimitRejects (limit=1, prior=null,
   qty=2 → reject; repo `add` must never be called).

Now 7/7 passing in PreProcessReservationTaskConcurrencyTest.
… DQL

getDiscoverableByEmailForSummit loaded ALL 6 discoverable subtypes for
the entire summit, then filtered in PHP — O(all codes) hydrations.

Split into two focused methods:
- getDomainAuthorizedDiscoverableForSummit: fetches only the 2 DA types,
  filters by email domain in PHP (unavoidable pattern match)
- getEmailLinkedDiscoverableForSummit: 4 DQL queries (one per Member/
  Speaker × Promo/Discount subtype) push the email filter into the WHERE
  clause, including the MemberPromoCodeTrait owner-fallback and the
  PresentationSpeaker member/registration_request two-hop chain

Both methods add isLive() date filtering in DQL (:now parameter),
matching the codebase convention from DoctrineSummitRepository.

The facade method delegates to both and merges, preserving the existing
caller contract (discoverPromoCodes and its exhaustion/quota logic are
untouched).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Switch INSTANCE OF from :param binding to inline class interpolation,
  matching the original code's pattern and avoiding Doctrine discriminator
  binding edge cases
- Add explicit parentheses around isLive DQL condition for defensive
  clarity (andWhere already wraps, but now consistent with email-linked)
- Fix member email fallback: PHP empty() matches both NULL and '', but
  DQL only checked IS NULL — now also checks e.email = '' to match
  MemberPromoCodeTrait::getEmail() exactly

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
… retry

Two bugs found by Codex review of the TOCTOU fix:

1. restoreTicketsPromoCodes (cancel/refund path) called removeUsage()
   on the promo code but never decremented the per-member reservation
   counter (SummitPromoCodeMemberReservation.QtyUsed). After a
   legitimate cancellation, discovery showed quota available but
   checkout rejected on the stale counter. Fix: add ?Member $owner
   parameter, decrement the reservation row after successful
   removeUsage() in the same try block. Non-ValidationException errors
   from the reservation path propagate and roll back the transaction.
   All 4 call sites updated.

2. PreProcessReservationTask::undo() set $undone=true before the loop,
   making partial failures unrecoverable — failed entries were cleared
   from $reserved unconditionally. Fix: build a $remaining list of
   failed entries, set $reserved=$remaining and $undone=empty($remaining)
   so a retry re-processes only the failed codes.

New test file RestorePathReservationTest (6 cases) exercises the real
restoreTicketsPromoCodes path via reflection: successful decrement,
guest order skip, missing reservation row skip, over-decrement clamp,
and non-ValidationException propagation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
caseylocker added a commit that referenced this pull request Apr 16, 2026
Addresses smarcet's PR #525 review:
- Replace raw CREATE TABLE / ADD COLUMN with LaravelDoctrine Builder +
  Table fluent API (matches Version20231208171355, Version20260407003923).
- Reorder down(): remap ClassName discriminator before dropping joined
  tables so an interrupted rollback can't leave the enum pointing at
  non-existent tables (DROP TABLE implicit-commit hazard).
- Keep ENUM MODIFY as raw addSql (Doctrine Schema has no MySQL ENUM
  support; matches Version20231208172204).
- Verified with down/up round-trip on local model DB; Codex audit clean.
caseylocker added a commit that referenced this pull request Apr 16, 2026
Addresses smarcet's PR #525 review items #8 and #9:
- Widen the .tld branch regex from /^\.\w+$/ to
  /^\.[a-z0-9]+(?:\.[a-z0-9]+)*$/i so admins can register suffixes like
  .co.uk, .com.au, .ac.uk via the API. The runtime matcher in
  DomainAuthorizedPromoCodeTrait::matchesEmailDomain already supported
  these via str_ends_with — the validator was incorrectly narrower than
  the runtime.
- Add tests/Unit/Rules/AllowedEmailDomainsArrayTest covering 31 cases:
  valid single/multi-label TLDs, @Domain, exact email, uppercase,
  trimmed whitespace, mixed valid; plus rejection of non-arrays, empty
  and whitespace-only entries, non-string elements, nested arrays, and
  malformed patterns (., ..com, .com., .co..uk, .-edu, @, @.com, bare
  tokens, one-bad-apple arrays). Uses @dataProvider doc-comment style
  to match the existing precedent (AbstractOAuth2ApiScopesTest).
caseylocker added a commit that referenced this pull request Apr 16, 2026
…layer)

Schema + data layer for the per-member QuantityPerAccount counter that
will fix the TOCTOU race smarcet flagged in PR #525 (and reproduced in
PR #530). This commit is intentionally NOT wired into the order-reserve
saga yet — the table sits unused until the follow-up commit that
modifies PreProcessReservationTask.

- Entity SummitPromoCodeMemberReservation (SilverstripeBaseModel) with
  unique (PromoCodeID, MemberID) and ManyToOne FKs cascading on delete.
- ISummitPromoCodeMemberReservationRepository + Doctrine impl. Readers
  from the reservation path rely on the outer row lock already held on
  the parent promo code (getByValueExclusiveLock) for serialization,
  so the repo does not take its own PESSIMISTIC_WRITE.
- Two migrations, split so CREATE TABLE commits before the backfill
  INSERT runs (Builder defers schema diff to end-of-migration, so
  INSERT-in-same-migration hits "table doesn't exist"):
    Version20260415191521 — create table via Builder/Table API.
    Version20260415191522 — backfill from existing committed tickets
                            (ON DUPLICATE KEY UPDATE for idempotency).
- RepositoriesProvider binding.
- Verified: down/up round-trip on docker MySQL; php -l clean.
caseylocker added a commit that referenced this pull request Apr 16, 2026
…ests

Step 3 of 3. The per-member QuantityPerAccount check now lives solely
in PreProcessReservationTask::reserveMemberQuotas (added in ad113d5,
leak-guarded in 77c3059), where it runs atomically under the
PESSIMISTIC_WRITE row lock on the parent promo code, BEFORE
ReserveTicketsTask and ReserveOrderTask commit any tickets.

Changes:

- Remove the belt-and-suspenders post-facto check from
  ApplyPromoCodeTask::run. A comment now points at the pre-reservation
  location and links to smarcet's TOCTOU reproduction for context.
  The old check counted committed tickets and could not distinguish
  concurrent orders' rows — see the race narrative in PR #525.

- Delete tests/Unit/Services/ApplyPromoCodeTaskConcurrencyTest.php.
  smarcet added this in the cherry-picked 2e0ef84 to prove the TOCTOU
  bug against the old check surface (static
  getTicketCountByMemberAndPromoCode). That surface is no longer in
  the write path, so the test targets removed code. The narrative is
  preserved — and extended — in the new file below.

- Delete tests/Unit/Services/ApplyPromoCodeTaskQuantityPerAccountTest.php.
  All six cases validated the exact post-facto check that was just
  removed.

- Add tests/Unit/Services/PreProcessReservationTaskConcurrencyTest.php
  with 6 cases exercising the new surface via reflection on
  reserveMemberQuotas:
    1. First reserve succeeds when no prior row exists (repo `add`
       called with qty_used=1).
    2. Second reserve rejects when the prior row's QtyUsed already
       sits at the limit (the serialized-second-request flow that
       replaces smarcet's TOCTOU reproduction).
    3. Within-limit reserve increments the existing row.
    4. Limit = 0 bypasses reservation entirely (unlimited per account).
    5. Non-IDomainAuthorizedPromoCode codes are skipped (no
       reservation repo calls).
    6. undo() decrements each reserved counter exactly once and is
       idempotent via the $undone guard.

Verified:
  docker exec summit-api composer dump-autoload    # pick up new entity
  docker exec summit-api vendor/bin/phpunit --filter "PreProcessReservationTask|SagaCompensationTest|ApplyPromoCodeTask"
    → 13/13 pass (3 PHPUnit deprecations match repo baseline).

Outstanding from smarcet's PR #525 review: #7 (discoverPromoCodes N+1)
is the only remaining item.
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

@caseylocker
Copy link
Copy Markdown
Contributor Author

Review Response — All 10 Items Addressed

This push (12 commits, b9f258242ec9f7301a) addresses every review item. Here's the map:

Migration & Pattern Fixes

# Item Commit(s)
1 Controller should use SerializerUtils c4bcdef33 (pre-existing)
3 New discover endpoint needs migration Version20260412000000 (pre-existing)
4 Don't use raw addSql in migration b9f258242
5 Migration down() ordering b9f258242

TOCTOU Fix (the big one)

# Item Commit(s)
2 Saga reorder rationale / regression test Pre-existing + PreProcessReservationTaskConcurrencyTest
6 TOCTOU race in QuantityPerAccount b1f3abea9ec9f7301a (9 commits)
10 ReserveOrderTask::undo compensation test SagaCompensationTest (pre-existing)

The TOCTOU fix replaces the unfixable post-facto count with a durable per-member reservation counter (SummitPromoCodeMemberReservation) incremented atomically under PESSIMISTIC_WRITE in PreProcessReservationTask, before any tickets are committed. The cancel/refund path (restoreTicketsPromoCodes) now decrements this counter alongside removeUsage(). PR #530's test was cherry-picked as the starting point. 13 new test cases across two test files.

Discover Query Optimization

# Item Commit(s)
7 discoverPromoCodes loads all codes 4db605564 + 999caa1ac

Split into domain-authorized (PHP filter, unavoidable) + 4 targeted DQL queries per email-linked subtype, pushing email matching and isLive() date filtering into the database.

Validation Fix

# Item Commit(s)
8 AllowedEmailDomainsArray rejects multi-level TLDs c62c0f782
9 No tests for AllowedEmailDomainsArray c62c0f782 (31 cases)

Test Summary

55 unit tests pass (49 existing + 6 new restore-path tests). The 6 OAuth2 discover integration tests have pre-existing failures (Unknown column t1.Source) unrelated to this PR.

The SummitOrderService constructor gained an
ISummitPromoCodeMemberReservationRepository parameter in
b1f3abe but this test was not updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

@caseylocker caseylocker force-pushed the feature/promo-codes-early-registration branch from d01bbc0 to a1bd4ea Compare April 16, 2026 02:58
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

@caseylocker caseylocker requested a review from smarcet April 16, 2026 04:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants