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
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`.
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());
}
}
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);
}
}
4 changes: 4 additions & 0 deletions backend/app/Helper/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ 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 WELCOME = 'app.frontend_urls.welcome';
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]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace HiEvents\Http\Actions\Accounts\Subscription;

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

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

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

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

try {
$url = $this->handler->handle($accountId);
} catch (StripeClientConfigurationException|ApiErrorException $exception) {
$this->logger->error('Failed to create a subscription portal session', [
'account_id' => $accountId,
'error' => $exception->getMessage(),
]);

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

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