Files
preregister/documentation/CURSOR-PROMPT-WEEZTIX.md
bert.hausmans d3abdb7ed9 feat: add Weeztix OAuth, coupon codes, and Mailwizz mapping
Implement Weeztix integration per documentation: database config and
subscriber coupon_code, OAuth redirect/callback, admin setup UI with
company/coupon selection via AJAX, synchronous coupon creation on public
subscribe with duplicate and rate-limit handling, Mailwizz field mapping
for coupon codes, subscriber table and CSV export, and connection hint
on the pages list.

Made-with: Cursor
2026-04-04 14:52:41 +02:00

25 KiB

Cursor Prompt — Weeztix Coupon Code Integration

Paste this into Cursor chat with @Codebase and @PreRegister-Development-Prompt.md as context.


Role & Context

You are a senior full-stack developer and integration architect. You're working on the PreRegister Laravel 11 application (Blade + Tailwind CSS + Alpine.js). No React, no Vue, no Livewire.

The application already has a Mailwizz integration that syncs subscribers to an email marketing platform. You are now adding a second integration: Weeztix — a ticket sales platform. When a visitor pre-registers on a page, a unique coupon code is generated via the Weeztix API and assigned to the subscriber. This coupon code can then be forwarded to Mailwizz so the subscriber receives a personalized discount email.


Part 1: Weeztix Platform Concepts

Coupon vs CouponCode

Weeztix separates these into two resources:

  • Coupon = a template/definition. Defines the discount type (percentage, fixed amount), value, what it applies to (orders or products). The Coupon is configured by the event organizer in Weeztix dashboard.
  • CouponCode = the actual code a visitor enters in the ticket shop to get the discount. Each CouponCode belongs to a Coupon and inherits its discount settings. CouponCodes are unique strings like PREREG-A7X9K2.

In our flow: The user selects an existing Coupon in the backend. When a visitor registers, we create a unique CouponCode under that Coupon via the API.

Authentication: OAuth2 Authorization Code Grant

Weeztix uses the OAuth2 Authorization Code flow. Key details:

Endpoints:

Action Method URL
Authorize (redirect user) GET https://auth.openticket.tech/tokens/authorize
Exchange code for token POST https://auth.openticket.tech/tokens
Refresh token POST https://auth.openticket.tech/tokens
API requests Various https://api.weeztix.com/...

Authorization redirect parameters:

https://auth.openticket.tech/tokens/authorize?
  client_id={OAUTH_CLIENT_ID}
  &redirect_uri={OAUTH_CLIENT_REDIRECT}
  &response_type=code
  &state={random_state}

Token exchange (POST to https://auth.openticket.tech/tokens):

{
    "grant_type": "authorization_code",
    "client_id": "...",
    "client_secret": "...",
    "redirect_uri": "...",
    "code": "..."
}

Token response:

{
    "token_type": "Bearer",
    "expires_in": 259200,
    "access_token": "THE_ACTUAL_TOKEN",
    "refresh_token": "REFRESH_TOKEN",
    "refresh_token_expires_in": 31535999
}
  • access_token expires in ~3 days (259200 seconds)
  • refresh_token expires in ~365 days, can only be used once

Refresh token (POST to https://auth.openticket.tech/tokens):

{
    "grant_type": "refresh_token",
    "refresh_token": "...",
    "client_id": "...",
    "client_secret": "..."
}

Returns a new access_token and a new refresh_token.

API requests require:

  • Header: Authorization: Bearer {access_token}
  • Header: Company: {company_guid} (to scope requests to a specific company)

API Endpoints We Need

Action Method URL Notes
Get coupons GET https://api.weeztix.com/coupon Returns coupons for the company (Company header)
Add coupon codes POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode Creates one or more codes under a coupon

Add CouponCodes request body:

{
    "usage_count": 1,
    "applies_to_count": null,
    "codes": [
        {
            "code": "PREREG-A7X9K2"
        }
    ]
}
  • usage_count: how many times the code can be used (1 = single use per subscriber)
  • applies_to_count: null = unlimited items in the order can use the discount
  • codes: array of code objects, each with a unique code string

Important: Duplicate CouponCodes cannot be added to a Coupon. Generate unique codes.


Part 2: Database Changes

New Migration: weeztix_configs table

Schema::create('weeztix_configs', function (Blueprint $table) {
    $table->id();
    $table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete();

    // OAuth credentials (encrypted at rest)
    $table->text('client_id');
    $table->text('client_secret');
    $table->string('redirect_uri');

    // OAuth tokens (encrypted at rest)
    $table->text('access_token')->nullable();
    $table->text('refresh_token')->nullable();
    $table->timestamp('token_expires_at')->nullable();
    $table->timestamp('refresh_token_expires_at')->nullable();

    // Company context
    $table->string('company_guid')->nullable();
    $table->string('company_name')->nullable();

    // Selected coupon
    $table->string('coupon_guid')->nullable();
    $table->string('coupon_name')->nullable();

    // CouponCode settings
    $table->string('code_prefix')->default('PREREG');  // prefix for generated codes
    $table->integer('usage_count')->default(1);         // how many times a code can be used

    $table->boolean('is_connected')->default(false);    // OAuth flow completed?
    $table->timestamps();
});

Modify subscribers table

Add a column to store the generated coupon code per subscriber:

Schema::table('subscribers', function (Blueprint $table) {
    $table->string('coupon_code')->nullable()->after('synced_at');
});

Model: WeeztixConfig

class WeeztixConfig extends Model
{
    protected $fillable = [
        'preregistration_page_id', 'client_id', 'client_secret', 'redirect_uri',
        'access_token', 'refresh_token', 'token_expires_at', 'refresh_token_expires_at',
        'company_guid', 'company_name', 'coupon_guid', 'coupon_name',
        'code_prefix', 'usage_count', 'is_connected',
    ];

    protected $casts = [
        'client_id' => 'encrypted',
        'client_secret' => 'encrypted',
        'access_token' => 'encrypted',
        'refresh_token' => 'encrypted',
        'token_expires_at' => 'datetime',
        'refresh_token_expires_at' => 'datetime',
        'is_connected' => 'boolean',
    ];

    public function preregistrationPage(): BelongsTo
    {
        return $this->belongsTo(PreregistrationPage::class);
    }

    public function isTokenExpired(): bool
    {
        return !$this->token_expires_at || $this->token_expires_at->isPast();
    }

    public function isRefreshTokenExpired(): bool
    {
        return !$this->refresh_token_expires_at || $this->refresh_token_expires_at->isPast();
    }
}

Update PreregistrationPage model

Add:

public function weeztixConfig(): HasOne
{
    return $this->hasOne(WeeztixConfig::class);
}

Part 3: WeeztixService

Create app/Services/WeeztixService.php — encapsulates all Weeztix API communication with automatic token refresh.

class WeeztixService
{
    private WeeztixConfig $config;

    public function __construct(WeeztixConfig $config)
    {
        $this->config = $config;
    }

    /**
     * Get a valid access token, refreshing if necessary.
     * Updates the config model with new tokens.
     */
    public function getValidAccessToken(): string

    /**
     * Refresh the access token using the refresh token.
     * Stores the new tokens in the config.
     * Throws exception if refresh token is also expired (re-auth needed).
     */
    private function refreshAccessToken(): void

    /**
     * Make an authenticated API request.
     * Automatically refreshes token if expired.
     */
    private function apiRequest(string $method, string $url, array $data = []): array

    /**
     * Get all companies the token has access to.
     * GET https://auth.weeztix.com/users/me (or similar endpoint)
     */
    public function getCompanies(): array

    /**
     * Get all coupons for the configured company.
     * GET https://api.weeztix.com/coupon
     * Header: Company: {company_guid}
     */
    public function getCoupons(): array

    /**
     * Create a unique coupon code under the configured coupon.
     * POST https://api.weeztix.com/coupon/{coupon_guid}/couponCode
     * Header: Company: {company_guid}
     */
    public function createCouponCode(string $code): array

    /**
     * Generate a unique code string.
     * Format: {prefix}-{random alphanumeric}
     * Example: PREREG-A7X9K2
     */
    public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
}

Key Implementation Details for WeeztixService

Token refresh logic:

1. Check if access_token exists and is not expired
2. If expired, check if refresh_token exists and is not expired
3. If refresh_token valid: POST to https://auth.openticket.tech/tokens with grant_type=refresh_token
4. Store new access_token, refresh_token, and their expiry timestamps
5. If refresh_token also expired: mark config as disconnected, throw exception (user must re-authorize)

API request wrapper:

private function apiRequest(string $method, string $url, array $data = []): array
{
    $token = $this->getValidAccessToken();

    $response = Http::withHeaders([
        'Authorization' => "Bearer {$token}",
        'Company' => $this->config->company_guid,
    ])->{$method}($url, $data);

    if ($response->status() === 401) {
        // Token might have been revoked, try refresh once
        $this->refreshAccessToken();
        $token = $this->config->access_token;

        $response = Http::withHeaders([
            'Authorization' => "Bearer {$token}",
            'Company' => $this->config->company_guid,
        ])->{$method}($url, $data);
    }

    if ($response->failed()) {
        Log::error('Weeztix API request failed', [
            'url' => $url,
            'status' => $response->status(),
            'body' => $response->json(),
        ]);
        throw new \RuntimeException("Weeztix API request failed: {$response->status()}");
    }

    return $response->json();
}

Unique code generation:

public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string
{
    $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O/1/I to avoid confusion
    $code = '';
    for ($i = 0; $i < $length; $i++) {
        $code .= $chars[random_int(0, strlen($chars) - 1)];
    }
    return strtoupper($prefix) . '-' . $code;
}

Part 4: OAuth Flow — Routes & Controller

Routes

// Inside the admin auth middleware group:

// Weeztix configuration (per page)
Route::get('pages/{page}/weeztix', [WeeztixController::class, 'edit'])->name('pages.weeztix.edit');
Route::put('pages/{page}/weeztix', [WeeztixController::class, 'update'])->name('pages.weeztix.update');
Route::delete('pages/{page}/weeztix', [WeeztixController::class, 'destroy'])->name('pages.weeztix.destroy');

// OAuth callback (needs to be accessible during OAuth flow)
Route::get('weeztix/callback', [WeeztixOAuthController::class, 'callback'])->name('weeztix.callback');

// AJAX endpoints for dynamic loading
Route::post('weeztix/coupons', [WeeztixApiController::class, 'coupons'])->name('weeztix.coupons');

OAuth Flow

Step 1: User enters client_id and client_secret in the backend form.

Step 2: User clicks "Connect to Weeztix" button.

The controller builds the authorization URL and redirects:

public function redirect(PreregistrationPage $page)
{
    $config = $page->weeztixConfig;
    $state = Str::random(40);

    // Store state + page ID in session for the callback
    session(['weeztix_oauth_state' => $state, 'weeztix_page_id' => $page->id]);

    $query = http_build_query([
        'client_id' => $config->client_id,
        'redirect_uri' => route('admin.weeztix.callback'),
        'response_type' => 'code',
        'state' => $state,
    ]);

    return redirect("https://auth.openticket.tech/tokens/authorize?{$query}");
}

Step 3: Weeztix redirects back to our callback URL with code and state.

public function callback(Request $request)
{
    // Verify state
    $storedState = session('weeztix_oauth_state');
    $pageId = session('weeztix_page_id');
    abort_if($request->state !== $storedState, 403, 'Invalid state');

    // Exchange code for tokens
    $response = Http::post('https://auth.openticket.tech/tokens', [
        'grant_type' => 'authorization_code',
        'client_id' => $config->client_id,
        'client_secret' => $config->client_secret,
        'redirect_uri' => route('admin.weeztix.callback'),
        'code' => $request->code,
    ]);

    // Store tokens in weeztix_configs
    $config->update([
        'access_token' => $data['access_token'],
        'refresh_token' => $data['refresh_token'],
        'token_expires_at' => now()->addSeconds($data['expires_in']),
        'refresh_token_expires_at' => now()->addSeconds($data['refresh_token_expires_in']),
        'is_connected' => true,
    ]);

    // Redirect back to the Weeztix config page
    return redirect()->route('admin.pages.weeztix.edit', $pageId)
        ->with('success', 'Successfully connected to Weeztix!');
}

Step 4: After OAuth, the user selects a Company and Coupon (see Part 5).


Part 5: Backend Configuration UI

Weeztix Configuration Page (tab/section within page edit)

Build a multi-step configuration interface similar to the Mailwizz config:

┌─── Weeztix Integration ────────────────────────────────┐
│                                                         │
│  Step 1: OAuth Credentials                              │
│  ┌────────────────────────────────────────────────┐     │
│  │  Client ID:     [________________________]     │     │
│  │  Client Secret: [________________________]     │     │
│  │  Redirect URI:  https://preregister.crewli.nl  │     │
│  │                 /admin/weeztix/callback         │     │
│  │                 (auto-generated, read-only)     │     │
│  │                                                 │     │
│  │  [Connect to Weeztix]  ← OAuth redirect button │     │
│  └────────────────────────────────────────────────┘     │
│                                                         │
│  Step 2: Select Coupon (shown after OAuth success)      │
│  ┌────────────────────────────────────────────────┐     │
│  │  Status: ✅ Connected                          │     │
│  │  Token expires: 2026-04-07 14:30               │     │
│  │                                                 │     │
│  │  Coupon: [▼ Select a coupon          ]         │     │
│  │          - Early Bird 20% discount              │     │
│  │          - Pre-register €5 korting              │     │
│  │                                                 │     │
│  │  Code Prefix: [PREREG  ]                        │     │
│  │  Usage per Code: [1]                            │     │
│  │                                                 │     │
│  │  [Save Configuration]                           │     │
│  └────────────────────────────────────────────────┘     │
│                                                         │
│  [Disconnect Weeztix]                                   │
└─────────────────────────────────────────────────────────┘

Instructions to show the user:

"Before connecting, create an OAuth Client in your Weeztix dashboard and set the redirect URI to: {route('admin.weeztix.callback')}. Also create a Coupon with the desired discount settings. You will select this Coupon here after connecting."

Loading Coupons (AJAX): After successful OAuth, use Alpine.js to fetch coupons via:

POST /admin/weeztix/coupons
Body: { page_id: ... }

The controller uses WeeztixService::getCoupons() and returns the list as JSON.


Part 6: Frontend — Coupon Code Generation on Registration

Updated Subscription Flow

When a visitor registers on a public page:

1. Validate form input
2. Check for duplicate email
3. Store subscriber in database
4. IF Weeztix is configured AND connected:
   a. Generate a unique coupon code: PREREG-A7X9K2
   b. Create the coupon code in Weeztix via API
   c. Store the coupon code on the subscriber record
5. IF Mailwizz is configured:
   a. Dispatch SyncSubscriberToMailwizz job
   b. The job should include the coupon_code in the Mailwizz subscriber data
      (if a Mailwizz field mapping for coupon_code is configured)
6. Return success with thank-you message

Job: CreateWeeztixCouponCode

Create a queued job (or execute synchronously if speed is critical — the visitor should see the coupon code in the thank-you message):

class CreateWeeztixCouponCode implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        private Subscriber $subscriber
    ) {}

    public function handle(): void
    {
        $page = $this->subscriber->preregistrationPage;
        $config = $page->weeztixConfig;

        if (!$config || !$config->is_connected || !$config->coupon_guid) {
            return;
        }

        $service = new WeeztixService($config);
        $code = WeeztixService::generateUniqueCode($config->code_prefix);

        try {
            $service->createCouponCode($code);

            $this->subscriber->update(['coupon_code' => $code]);
        } catch (\Exception $e) {
            Log::error('Failed to create Weeztix coupon code', [
                'subscriber_id' => $this->subscriber->id,
                'error' => $e->getMessage(),
            ]);
            throw $e; // Let the job retry
        }
    }
}

IMPORTANT DECISION: Sync vs Async

If the coupon code should be shown in the thank-you message immediately after registration, the Weeztix API call must be synchronous (not queued). In that case, call the service directly in PublicPageController@subscribe instead of dispatching a job:

// In PublicPageController@subscribe, after storing the subscriber:
if ($page->weeztixConfig && $page->weeztixConfig->is_connected) {
    try {
        $service = new WeeztixService($page->weeztixConfig);
        $code = WeeztixService::generateUniqueCode($page->weeztixConfig->code_prefix);
        $service->createCouponCode($code);
        $subscriber->update(['coupon_code' => $code]);
    } catch (\Exception $e) {
        Log::error('Weeztix coupon creation failed', ['error' => $e->getMessage()]);
        // Don't fail the registration — the subscriber is already saved
    }
}

// Then dispatch the Mailwizz sync job (which includes the coupon_code)
if ($page->mailwizzConfig) {
    SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}

return response()->json([
    'success' => true,
    'message' => $page->thank_you_message ?? 'Bedankt voor je registratie!',
    'coupon_code' => $subscriber->coupon_code, // null if Weeztix not configured
]);

Show Coupon Code in Thank-You State

Update the Alpine.js success state to display the coupon code if present:

<div x-show="submitted" class="text-center text-white">
    <p x-text="successMessage"></p>

    <template x-if="couponCode">
        <div class="mt-6 bg-white/10 backdrop-blur rounded-xl p-6 border border-white/20">
            <p class="text-sm text-white/70 mb-2">Jouw kortingscode:</p>
            <div class="flex items-center justify-center gap-3">
                <span class="text-2xl font-mono font-bold tracking-wider text-orange-400"
                      x-text="couponCode"></span>
                <button @click="copyCode()"
                        class="text-white/50 hover:text-white transition">
                    <!-- Copy icon (Heroicon clipboard) -->
                    <svg>...</svg>
                </button>
            </div>
            <p class="text-xs text-white/50 mt-3">
                Gebruik deze code bij het afrekenen in de ticketshop.
            </p>
        </div>
    </template>
</div>

Part 7: Mailwizz Integration — Forward Coupon Code

Add Coupon Code Field Mapping to Mailwizz Config

Add a new field to mailwizz_configs:

// New migration
Schema::table('mailwizz_configs', function (Blueprint $table) {
    $table->string('field_coupon_code')->nullable()->after('field_phone');
    // This maps to a Mailwizz custom field (e.g., 'COUPON' tag)
});

Update Mailwizz Configuration Wizard

In the field mapping step, add an additional mapping:

  • Coupon Code → show text fields from the Mailwizz list → let user select the field where the coupon code should be stored (e.g., a custom field with tag COUPON)
  • This mapping is optional — only shown if Weeztix is also configured

Update SyncSubscriberToMailwizz Job

When building the Mailwizz subscriber data array, include the coupon code if the mapping exists:

// In the sync job, when building the data array:
if ($config->field_coupon_code && $subscriber->coupon_code) {
    $data[$config->field_coupon_code] = $subscriber->coupon_code;
}

This allows the Mailwizz email template to include [COUPON] as a merge tag, so each subscriber receives their personal coupon code in the email.


Part 8: Subscriber Management — Show Coupon Codes

Update Subscribers Index

Add the coupon code column to the subscribers table in the backend:

First Name Last Name Email Phone Coupon Code Synced Registered
Bert Hausmans bert@... +316... PREREG-A7X9K2 2026-04-04

Update CSV Export

Add the coupon_code column to the CSV export.


Part 9: Implementation Order

Step 1: Database

  • Create weeztix_configs migration
  • Add coupon_code to subscribers migration
  • Add field_coupon_code to mailwizz_configs migration
  • Create WeeztixConfig model
  • Update PreregistrationPage model with weeztixConfig relationship
  • Run migrations

Step 2: WeeztixService

  • Create app/Services/WeeztixService.php
  • Implement token management (get valid token, refresh, detect expiry)
  • Implement getCoupons() and createCouponCode()
  • Add unique code generation

Step 3: OAuth Flow

  • Create WeeztixOAuthController (redirect + callback)
  • Add routes for OAuth
  • Handle state validation and token storage

Step 4: Backend Configuration UI

  • Create WeeztixController (edit, update, destroy)
  • Create WeeztixApiController (coupons AJAX endpoint)
  • Create Blade view admin/weeztix/edit.blade.php
  • Build the multi-step form with Alpine.js
  • Add link/tab in the page edit navigation

Step 5: Frontend Coupon Generation

  • Update PublicPageController@subscribe to generate coupon codes
  • Update Alpine.js success state to show coupon code
  • Add copy-to-clipboard functionality

Step 6: Mailwizz Coupon Forwarding

  • Update Mailwizz config migration and model
  • Update Mailwizz configuration wizard (add coupon_code field mapping)
  • Update SyncSubscriberToMailwizz job to include coupon code

Step 7: Backend Updates

  • Update subscribers index to show coupon codes
  • Update CSV export to include coupon codes
  • Add Weeztix connection status indicator on the pages index

Step 8: Error Handling & Edge Cases

  • Handle Weeztix API downtime (don't fail registration)
  • Handle expired refresh tokens (show "Reconnect" button)
  • Handle duplicate coupon codes (retry with new code)
  • Handle Weeztix rate limiting
  • Log all API interactions

Important Rules