Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/backend/account-deletion.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ paths:

- Hard delete can't rely on cascades (many foreign keys to events, orders and organizers are NO ACTION). A new account-owned table needs an anonymizer in `Services/Domain/Account/Anonymization/` and a place in the `AccountHardDeletionService` delete order.
- `account_deletion_requests` has no foreign keys on purpose, so rows survive the hard delete.
- `account_subscriptions` holds no personal data and has no anonymizer on purpose: anonymization keeps the row (and the Stripe customer, for invoices); hard delete removes it. Deletion cancels the Stripe subscriptions first (`AccountSubscriptionCancellationService`).
- Never delete `organizer_configurations` or `account_configuration` rows (shared plan rows) or `ticket_lookup_tokens` (buyer-email scoped across accounts). `stripe_customers` has no account foreign key; match it by Stripe account id.
- Users can belong to several accounts: a sole-account user is scrubbed and soft-deleted, and their `user_identities` rows are deleted so they can't sign back in with single sign-on; a shared user only loses the `account_users` row.
- An account's SSO connection, its domains and the `user_identities` tagged with the connection are deleted before `accounts` (`SsoConnectionAnonymizer` mirrors this).
Expand Down
3 changes: 2 additions & 1 deletion .claude/rules/backend/lifecycle-emails.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ paths:

- Send only through `MAIL_LIFECYCLE_MAILER`, test sends included; never fall back to the transactional mailer. Everything lifecycle exists only in SaaS mode (`LifecycleEmailAvailability`).
- Every candidate query must: exclude `users.lifecycle_emails_unsubscribed_at`; skip accounts nudged in the last 3 days (`quietPeriodStart()`); filter in SQL before `LIMIT`, never in PHP after it; and anchor its trigger on a timestamp old data can't move (never `updated_at` unless also bounded by a creation date after `enabled_at`). Marketing-flavoured nudges also require `users.marketing_opted_in_at`.
- A template's `enabled_at` is the cold-start cutoff; never backdate it.
- A template's `enabled_at` is the cold-start cutoff; never backdate it. The one exception is `LAPSED_PAYER`, which on purpose reaches accounts that lapsed before it was enabled, within a fixed 18-month lookback anchored on the end of the last sold date.
- Every candidate query skips suspended accounts (`accounts.suspended_at`).
- Unsubscribe is a button on `/emails/unsubscribe/:token`, never unsubscribe-on-GET (link scanners prefetch).
- `account_lifecycle_nudges` is claim-then-send: only transient failures (network, HTTP 5xx/429) release the claim and stop the sweep; any other rejection keeps the claim so one bad address never stalls it. Nudges are sent synchronously, not via `BaseMail`, so failures can be classified.
- Stripe nudges filter by signup timezone (`StripeConnectTimezones`); don't use `organizer_stripe_platforms.stripe_account_details->country`, because unfinished Express accounts report the platform default (`IE`).
1 change: 1 addition & 0 deletions .claude/rules/backend/mail.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ paths:
- Link `email_deliveries`, `outgoing_messages` and `account_lifecycle_nudges` through `provider_message_id`, never with foreign keys.
- Don't add Symfony `TagHeader`s for tracking: Postmark rejects more than one tag per email.
- Pick a delivery webhook receiver by the `{provider}` route segment (`EmailDeliveryWebhookReceiverResolver`), never by sniffing the payload. The webhook route is unthrottled on purpose: unauthenticated calls never touch the DB, and throttling Postmark's few IPs during a broadcast would drop events.
- Event-scoped mails (`ContainsEventContent`) for Hi.Events Pro accounts go out under the organizer's name and event accent colour (`MailBrandingService`); only the display name changes, the address stays `MAIL_FROM_ADDRESS`. A new event mail sent to the organizer or team, not buyers, must override `isSentToBuyers()` to return false (see `OrderSummaryForOrganizer`).
3 changes: 2 additions & 1 deletion .claude/rules/backend/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ paths:
- Adding a `DomainEventType` case means updating the webhook payload table in `config/scramble.php`.
- `backend/resources/api-reference/openapi.json` is the generated in-app API reference (Settings → API keys → API reference): Scramble output minus session-only operations and `/auth/*`, without `servers` and `info.version` (added when served). Regenerate it with `php artisan api-reference:export`; `IntegrationApiReferenceTest` fails when it drifts. Never edit it by hand.
- The docs intro in `config/scramble.php` promises that endpoints API keys can call stay backward compatible (see "Public API compatibility" in AGENTS.md) and lists the error codes. Update its error table when adding an `error_code`. Session-only operations are marked "Not available to API keys" automatically by `ScrambleServiceProvider`.
- Admin routes and the licensing endpoints (`Enterprise\Licensing` actions) are excluded from all generated docs in `ScrambleServiceProvider`.
- Admin routes, the licensing endpoints (`Enterprise\Licensing` actions) and the Hi.Events-only subscription endpoints (`Accounts\Subscription` actions) are excluded from all generated docs via `UNDOCUMENTED_ACTION_NAMESPACES` in `ScrambleServiceProvider`.
- Hi.Events-only response fields (e.g. `branding_removed` on public organizers) stay out of the generated docs: list them in `UNDOCUMENTED_PROPERTIES` in `ScrambleServiceProvider`.
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ concurrency:
jobs:
backend:
name: Deploy Backend
runs-on: ubuntu-latest
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v3
Expand Down Expand Up @@ -117,6 +117,7 @@ jobs:
env:
VAPOR_API_TOKEN: ${{ secrets.VAPOR_API_TOKEN }}
SENTRY_RELEASE: ${{ github.sha }}
BUILDX_NO_DEFAULT_ATTESTATIONS: 1

frontend:
name: Deploy Frontend
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ test_results/
/release-notes.md

.amp/portals/*
.cloudflare/
23 changes: 23 additions & 0 deletions backend/app/DomainObjects/AccountSubscriptionDomainObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace HiEvents\DomainObjects;

use HiEvents\DomainObjects\Status\AccountSubscriptionStatus;

class AccountSubscriptionDomainObject extends Generated\AccountSubscriptionDomainObjectAbstract
{
public function grantsAccess(): bool
{
return $this->status()?->grantsAccess() ?? false;
}

public function hasOpenSubscription(): bool
{
return $this->status()?->isOpen() ?? false;
}

private function status(): ?AccountSubscriptionStatus
{
return AccountSubscriptionStatus::tryFrom((string) $this->getStatus());
}
}
1 change: 1 addition & 0 deletions backend/app/DomainObjects/Enums/LifecycleNudgeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ enum LifecycleNudgeType
case STRIPE_ONBOARDING_INCOMPLETE;
case EVENT_DRAFT_STALE;
case LIVE_EVENT_NO_SALES;
case LAPSED_PAYER;
}
19 changes: 19 additions & 0 deletions backend/app/DomainObjects/Enums/SubscriptionBillingInterval.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum SubscriptionBillingInterval: string
{
use BaseEnum;

case MONTH = 'month';
case YEAR = 'year';

public function priceLookupKey(): string
{
return match ($this) {
self::MONTH => 'hi_events_pro_monthly',
self::YEAR => 'hi_events_pro_yearly',
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<?php

namespace HiEvents\DomainObjects\Generated;

/**
* THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY.
* @package HiEvents\DomainObjects\Generated
*/
abstract class AccountSubscriptionDomainObjectAbstract extends \HiEvents\DomainObjects\AbstractDomainObject
{
final public const SINGULAR_NAME = 'account_subscription';
final public const PLURAL_NAME = 'account_subscriptions';
final public const ID = 'id';
final public const ACCOUNT_ID = 'account_id';
final public const STRIPE_CUSTOMER_ID = 'stripe_customer_id';
final public const STRIPE_SUBSCRIPTION_ID = 'stripe_subscription_id';
final public const STATUS = 'status';
final public const CURRENT_PERIOD_END = 'current_period_end';
final public const CANCEL_AT_PERIOD_END = 'cancel_at_period_end';
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';

protected int $id;
protected int $account_id;
protected string $stripe_customer_id;
protected ?string $stripe_subscription_id = null;
protected ?string $status = null;
protected ?string $current_period_end = null;
protected bool $cancel_at_period_end = false;
protected ?string $created_at = null;
protected ?string $updated_at = null;

public function toArray(): array
{
return [
'id' => $this->id ?? null,
'account_id' => $this->account_id ?? null,
'stripe_customer_id' => $this->stripe_customer_id ?? null,
'stripe_subscription_id' => $this->stripe_subscription_id ?? null,
'status' => $this->status ?? null,
'current_period_end' => $this->current_period_end ?? null,
'cancel_at_period_end' => $this->cancel_at_period_end ?? null,
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
];
}

public function setId(int $id): self
{
$this->id = $id;
return $this;
}

public function getId(): int
{
return $this->id;
}

public function setAccountId(int $account_id): self
{
$this->account_id = $account_id;
return $this;
}

public function getAccountId(): int
{
return $this->account_id;
}

public function setStripeCustomerId(string $stripe_customer_id): self
{
$this->stripe_customer_id = $stripe_customer_id;
return $this;
}

public function getStripeCustomerId(): string
{
return $this->stripe_customer_id;
}

public function setStripeSubscriptionId(?string $stripe_subscription_id): self
{
$this->stripe_subscription_id = $stripe_subscription_id;
return $this;
}

public function getStripeSubscriptionId(): ?string
{
return $this->stripe_subscription_id;
}

public function setStatus(?string $status): self
{
$this->status = $status;
return $this;
}

public function getStatus(): ?string
{
return $this->status;
}

public function setCurrentPeriodEnd(?string $current_period_end): self
{
$this->current_period_end = $current_period_end;
return $this;
}

public function getCurrentPeriodEnd(): ?string
{
return $this->current_period_end;
}

public function setCancelAtPeriodEnd(bool $cancel_at_period_end): self
{
$this->cancel_at_period_end = $cancel_at_period_end;
return $this;
}

public function getCancelAtPeriodEnd(): bool
{
return $this->cancel_at_period_end;
}

public function setCreatedAt(?string $created_at): self
{
$this->created_at = $created_at;
return $this;
}

public function getCreatedAt(): ?string
{
return $this->created_at;
}

public function setUpdatedAt(?string $updated_at): self
{
$this->updated_at = $updated_at;
return $this;
}

public function getUpdatedAt(): ?string
{
return $this->updated_at;
}
}
14 changes: 14 additions & 0 deletions backend/app/DomainObjects/OrganizerDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ class OrganizerDomainObject extends Generated\OrganizerDomainObjectAbstract

private ?LocationDomainObject $locationRecord = null;

private ?bool $brandingRemoved = null;

public function getBrandingRemoved(): ?bool
{
return $this->brandingRemoved;
}

public function setBrandingRemoved(?bool $brandingRemoved): self
{
$this->brandingRemoved = $brandingRemoved;

return $this;
}

public function getImages(): ?Collection
{
return $this->images;
Expand Down
34 changes: 34 additions & 0 deletions backend/app/DomainObjects/Status/AccountSubscriptionStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace HiEvents\DomainObjects\Status;

use HiEvents\DomainObjects\Enums\BaseEnum;

enum AccountSubscriptionStatus: string
{
use BaseEnum;

case INCOMPLETE = 'INCOMPLETE';
case INCOMPLETE_EXPIRED = 'INCOMPLETE_EXPIRED';
case TRIALING = 'TRIALING';
case ACTIVE = 'ACTIVE';
case PAST_DUE = 'PAST_DUE';
case CANCELED = 'CANCELED';
case UNPAID = 'UNPAID';
case PAUSED = 'PAUSED';

public static function fromStripeStatus(string $stripeStatus): ?self
{
return self::tryFrom(strtoupper($stripeStatus));
}

public function grantsAccess(): bool
{
return in_array($this, [self::ACTIVE, self::TRIALING, self::PAST_DUE], true);
}

public function isOpen(): bool
{
return ! in_array($this, [self::CANCELED, self::INCOMPLETE_EXPIRED], true);
}
}
6 changes: 6 additions & 0 deletions backend/app/Helper/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,14 @@ class Url

public const ACCOUNT_SETTINGS = 'app.frontend_urls.account_settings';

public const ACCOUNT_BILLING = 'app.frontend_urls.account_billing';

public const REGISTER = 'app.frontend_urls.register';

public const EVENT_DASHBOARD = 'app.frontend_urls.event_dashboard';

public const ORGANIZER_EVENTS = 'app.frontend_urls.organizer_events';

public const WELCOME = 'app.frontend_urls.welcome';

public const LIFECYCLE_EMAIL_UNSUBSCRIBE = 'app.frontend_urls.lifecycle_email_unsubscribe';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace HiEvents\Http\Actions\Accounts\Subscription;

use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\Enums\Permission;
use HiEvents\DomainObjects\Enums\SubscriptionBillingInterval;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\Stripe\StripeClientConfigurationException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Attributes\RequiresPermission;
use HiEvents\Http\Request\Account\CreateSubscriptionCheckoutSessionRequest;
use HiEvents\Services\Application\Handlers\Account\Subscription\CreateSubscriptionCheckoutSessionHandler;
use HiEvents\Services\Application\Handlers\Account\Subscription\DTO\CreateSubscriptionCheckoutSessionDTO;
use Illuminate\Http\JsonResponse;
use Psr\Log\LoggerInterface;
use Stripe\Exception\ApiErrorException;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

#[RequiresPermission(Permission::MANAGE_ACCOUNT)]
class CreateSubscriptionCheckoutSessionAction extends BaseAction
{
public function __construct(
private readonly CreateSubscriptionCheckoutSessionHandler $handler,
private readonly LoggerInterface $logger,
) {}

/**
* @throws Throwable
*/
public function __invoke(int $accountId, CreateSubscriptionCheckoutSessionRequest $request): JsonResponse
{
$this->isActionAuthorized($accountId, AccountDomainObject::class);

if ($this->isImpersonating()) {
return $this->impersonationForbiddenResponse(__('You cannot start a subscription while impersonating a user.'));
}

try {
$url = $this->handler->handle(new CreateSubscriptionCheckoutSessionDTO(
accountId: $accountId,
interval: SubscriptionBillingInterval::from($request->validated('interval')),
));
} catch (ResourceConflictException $exception) {
return $this->errorResponse(
message: $exception->getMessage(),
statusCode: Response::HTTP_CONFLICT,
);
} catch (StripeClientConfigurationException|ApiErrorException $exception) {
$this->logger->error('Failed to create a subscription checkout session', [
'account_id' => $accountId,
'error' => $exception->getMessage(),
]);

return $this->errorResponse(
message: __('We couldn\'t open the payment page. Please try again later.'),
statusCode: Response::HTTP_SERVICE_UNAVAILABLE,
);
}

return $this->jsonResponse(['url' => $url]);
}
}
Loading
Loading