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
This commit is contained in:
2026-04-04 14:52:41 +02:00
parent 17e784fee7
commit d3abdb7ed9
30 changed files with 2272 additions and 5 deletions

View File

@@ -0,0 +1,709 @@
# 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`):**
```json
{
"grant_type": "authorization_code",
"client_id": "...",
"client_secret": "...",
"redirect_uri": "...",
"code": "..."
}
```
**Token response:**
```json
{
"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`):**
```json
{
"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:**
```json
{
"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
```php
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:
```php
Schema::table('subscribers', function (Blueprint $table) {
$table->string('coupon_code')->nullable()->after('synced_at');
});
```
### Model: `WeeztixConfig`
```php
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:
```php
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.
```php
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:**
```php
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:**
```php
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
```php
// 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:
```php
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`.**
```php
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):
```php
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:
```php
// 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:
```html
<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`:
```php
// 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:
```php
// 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
- **OAuth credentials must be encrypted** — use Laravel's `encrypted` cast
- **Token refresh must be automatic** — the user should never have to manually refresh
- **Registration must not fail** if Weeztix is down — coupon code is a bonus, not a requirement
- **Coupon codes must be unique** — use the unambiguous character set (no 0/O/1/I)
- **Blade + Alpine.js + Tailwind only** — no additional JS frameworks
- **All text in Dutch** — labels, messages, instructions
- **Don't break existing integrations** — Mailwizz sync must continue working, form blocks must remain functional
- **Refer to the Weeztix API documentation** for exact request/response formats:
- Authentication: https://docs.weeztix.com/docs/introduction/authentication/
- Request token: https://docs.weeztix.com/docs/introduction/authentication/request-token
- Refresh token: https://docs.weeztix.com/docs/introduction/authentication/refresh-token
- Get Coupons: https://docs.weeztix.com/api/dashboard/get-coupons
- Add CouponCodes: https://docs.weeztix.com/api/dashboard/add-coupon-codes
- Issuing requests (Company header): https://docs.weeztix.com/docs/introduction/issue-request/