diff --git a/backend/.gitignore b/backend/.gitignore index 67bc1bd0f2..793388378b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -19,3 +19,4 @@ yarn-error.log /.vscode .idea /app-back +bootstrap/cache diff --git a/backend/app/DomainObjects/AttendeeDomainObject.php b/backend/app/DomainObjects/AttendeeDomainObject.php index ee9ceeb994..84d443366a 100644 --- a/backend/app/DomainObjects/AttendeeDomainObject.php +++ b/backend/app/DomainObjects/AttendeeDomainObject.php @@ -29,7 +29,7 @@ public static function getAllowedSorts(): AllowedSorts [ self::CREATED_AT => [ 'asc' => __('Older First'), - 'desc' => __('Newer First'), + 'desc' => __('Newest First'), ], self::UPDATED_AT => [ 'desc' => __('Recently Updated First'), diff --git a/backend/app/DomainObjects/Enums/AttendeeCheckInActionType.php b/backend/app/DomainObjects/Enums/AttendeeCheckInActionType.php new file mode 100644 index 0000000000..cd0032c216 --- /dev/null +++ b/backend/app/DomainObjects/Enums/AttendeeCheckInActionType.php @@ -0,0 +1,11 @@ +getLocationDetails(); - - if (is_null($locationDetails)) { - return ''; - } - - $addressParts = [ - $locationDetails['venue_name'] ?? null, - $locationDetails['address_line_1'] ?? null, - $locationDetails['address_line_2'] ?? null, - $locationDetails['city'] ?? null, - $locationDetails['state_or_region'] ?? null, - $locationDetails['zip_or_postal_code'] ?? null, - $locationDetails['country'] ?? null - ]; - - $filteredAddressParts = array_filter($addressParts, static fn($part) => !is_null($part) && $part !== ''); - - return implode(', ', $filteredAddressParts); + return AddressHelper::formatAddress($this->getLocationDetails()); } public function getAddress(): AddressDTO diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index 03feec38a1..ed4088fd01 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -45,6 +45,19 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const SHOW_SHARE_BUTTONS = 'show_share_buttons'; final public const HOMEPAGE_BODY_BACKGROUND_COLOR = 'homepage_body_background_color'; final public const HOMEPAGE_BACKGROUND_TYPE = 'homepage_background_type'; + final public const ENABLE_INVOICING = 'enable_invoicing'; + final public const INVOICE_LABEL = 'invoice_label'; + final public const INVOICE_PREFIX = 'invoice_prefix'; + final public const INVOICE_START_NUMBER = 'invoice_start_number'; + final public const REQUIRE_BILLING_ADDRESS = 'require_billing_address'; + final public const ORGANIZATION_NAME = 'organization_name'; + final public const ORGANIZATION_ADDRESS = 'organization_address'; + final public const INVOICE_TAX_DETAILS = 'invoice_tax_details'; + final public const PAYMENT_PROVIDERS = 'payment_providers'; + final public const OFFLINE_PAYMENT_INSTRUCTIONS = 'offline_payment_instructions'; + final public const ALLOW_ORDERS_AWAITING_OFFLINE_PAYMENT_TO_CHECK_IN = 'allow_orders_awaiting_offline_payment_to_check_in'; + final public const INVOICE_PAYMENT_TERMS_DAYS = 'invoice_payment_terms_days'; + final public const INVOICE_NOTES = 'invoice_notes'; protected int $id; protected int $event_id; @@ -81,6 +94,19 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected bool $show_share_buttons = true; protected ?string $homepage_body_background_color = null; protected string $homepage_background_type = 'COLOR'; + protected bool $enable_invoicing = false; + protected ?string $invoice_label = null; + protected ?string $invoice_prefix = null; + protected int $invoice_start_number = 1; + protected bool $require_billing_address = true; + protected ?string $organization_name = null; + protected ?string $organization_address = null; + protected ?string $invoice_tax_details = null; + protected array|string|null $payment_providers = null; + protected ?string $offline_payment_instructions = null; + protected bool $allow_orders_awaiting_offline_payment_to_check_in = false; + protected ?int $invoice_payment_terms_days = null; + protected ?string $invoice_notes = null; public function toArray(): array { @@ -120,6 +146,19 @@ public function toArray(): array 'show_share_buttons' => $this->show_share_buttons ?? null, 'homepage_body_background_color' => $this->homepage_body_background_color ?? null, 'homepage_background_type' => $this->homepage_background_type ?? null, + 'enable_invoicing' => $this->enable_invoicing ?? null, + 'invoice_label' => $this->invoice_label ?? null, + 'invoice_prefix' => $this->invoice_prefix ?? null, + 'invoice_start_number' => $this->invoice_start_number ?? null, + 'require_billing_address' => $this->require_billing_address ?? null, + 'organization_name' => $this->organization_name ?? null, + 'organization_address' => $this->organization_address ?? null, + 'invoice_tax_details' => $this->invoice_tax_details ?? null, + 'payment_providers' => $this->payment_providers ?? null, + 'offline_payment_instructions' => $this->offline_payment_instructions ?? null, + 'allow_orders_awaiting_offline_payment_to_check_in' => $this->allow_orders_awaiting_offline_payment_to_check_in ?? null, + 'invoice_payment_terms_days' => $this->invoice_payment_terms_days ?? null, + 'invoice_notes' => $this->invoice_notes ?? null, ]; } @@ -507,4 +546,148 @@ public function getHomepageBackgroundType(): string { return $this->homepage_background_type; } + + public function setEnableInvoicing(bool $enable_invoicing): self + { + $this->enable_invoicing = $enable_invoicing; + return $this; + } + + public function getEnableInvoicing(): bool + { + return $this->enable_invoicing; + } + + public function setInvoiceLabel(?string $invoice_label): self + { + $this->invoice_label = $invoice_label; + return $this; + } + + public function getInvoiceLabel(): ?string + { + return $this->invoice_label; + } + + public function setInvoicePrefix(?string $invoice_prefix): self + { + $this->invoice_prefix = $invoice_prefix; + return $this; + } + + public function getInvoicePrefix(): ?string + { + return $this->invoice_prefix; + } + + public function setInvoiceStartNumber(int $invoice_start_number): self + { + $this->invoice_start_number = $invoice_start_number; + return $this; + } + + public function getInvoiceStartNumber(): int + { + return $this->invoice_start_number; + } + + public function setRequireBillingAddress(bool $require_billing_address): self + { + $this->require_billing_address = $require_billing_address; + return $this; + } + + public function getRequireBillingAddress(): bool + { + return $this->require_billing_address; + } + + public function setOrganizationName(?string $organization_name): self + { + $this->organization_name = $organization_name; + return $this; + } + + public function getOrganizationName(): ?string + { + return $this->organization_name; + } + + public function setOrganizationAddress(?string $organization_address): self + { + $this->organization_address = $organization_address; + return $this; + } + + public function getOrganizationAddress(): ?string + { + return $this->organization_address; + } + + public function setInvoiceTaxDetails(?string $invoice_tax_details): self + { + $this->invoice_tax_details = $invoice_tax_details; + return $this; + } + + public function getInvoiceTaxDetails(): ?string + { + return $this->invoice_tax_details; + } + + public function setPaymentProviders(array|string|null $payment_providers): self + { + $this->payment_providers = $payment_providers; + return $this; + } + + public function getPaymentProviders(): array|string|null + { + return $this->payment_providers; + } + + public function setOfflinePaymentInstructions(?string $offline_payment_instructions): self + { + $this->offline_payment_instructions = $offline_payment_instructions; + return $this; + } + + public function getOfflinePaymentInstructions(): ?string + { + return $this->offline_payment_instructions; + } + + public function setAllowOrdersAwaitingOfflinePaymentToCheckIn( + bool $allow_orders_awaiting_offline_payment_to_check_in, + ): self { + $this->allow_orders_awaiting_offline_payment_to_check_in = $allow_orders_awaiting_offline_payment_to_check_in; + return $this; + } + + public function getAllowOrdersAwaitingOfflinePaymentToCheckIn(): bool + { + return $this->allow_orders_awaiting_offline_payment_to_check_in; + } + + public function setInvoicePaymentTermsDays(?int $invoice_payment_terms_days): self + { + $this->invoice_payment_terms_days = $invoice_payment_terms_days; + return $this; + } + + public function getInvoicePaymentTermsDays(): ?int + { + return $this->invoice_payment_terms_days; + } + + public function setInvoiceNotes(?string $invoice_notes): self + { + $this->invoice_notes = $invoice_notes; + return $this; + } + + public function getInvoiceNotes(): ?string + { + return $this->invoice_notes; + } } diff --git a/backend/app/DomainObjects/Generated/InvoiceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/InvoiceDomainObjectAbstract.php new file mode 100644 index 0000000000..b67e8fde09 --- /dev/null +++ b/backend/app/DomainObjects/Generated/InvoiceDomainObjectAbstract.php @@ -0,0 +1,216 @@ + $this->id ?? null, + 'order_id' => $this->order_id ?? null, + 'account_id' => $this->account_id ?? null, + 'invoice_number' => $this->invoice_number ?? null, + 'issue_date' => $this->issue_date ?? null, + 'due_date' => $this->due_date ?? null, + 'total_amount' => $this->total_amount ?? null, + 'status' => $this->status ?? null, + 'items' => $this->items ?? null, + 'taxes_and_fees' => $this->taxes_and_fees ?? null, + 'uuid' => $this->uuid ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_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 setInvoiceNumber(string $invoice_number): self + { + $this->invoice_number = $invoice_number; + return $this; + } + + public function getInvoiceNumber(): string + { + return $this->invoice_number; + } + + public function setIssueDate(string $issue_date): self + { + $this->issue_date = $issue_date; + return $this; + } + + public function getIssueDate(): string + { + return $this->issue_date; + } + + public function setDueDate(?string $due_date): self + { + $this->due_date = $due_date; + return $this; + } + + public function getDueDate(): ?string + { + return $this->due_date; + } + + public function setTotalAmount(float $total_amount): self + { + $this->total_amount = $total_amount; + return $this; + } + + public function getTotalAmount(): float + { + return $this->total_amount; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setItems(array|string $items): self + { + $this->items = $items; + return $this; + } + + public function getItems(): array|string + { + return $this->items; + } + + public function setTaxesAndFees(array|string|null $taxes_and_fees): self + { + $this->taxes_and_fees = $taxes_and_fees; + return $this; + } + + public function getTaxesAndFees(): array|string|null + { + return $this->taxes_and_fees; + } + + public function setUuid(string $uuid): self + { + $this->uuid = $uuid; + return $this; + } + + public function getUuid(): string + { + return $this->uuid; + } + + 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; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php index 939ca2cd94..0d1e1ea2c4 100644 --- a/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OrderDomainObjectAbstract.php @@ -39,6 +39,8 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac final public const TOTAL_TAX = 'total_tax'; final public const TOTAL_FEE = 'total_fee'; final public const LOCALE = 'locale'; + final public const PAYMENT_PROVIDER = 'payment_provider'; + final public const NOTES = 'notes'; protected int $id; protected int $event_id; @@ -69,6 +71,8 @@ abstract class OrderDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac protected float $total_tax = 0.0; protected float $total_fee = 0.0; protected string $locale = 'en'; + protected ?string $payment_provider = null; + protected ?string $notes = null; public function toArray(): array { @@ -102,6 +106,8 @@ public function toArray(): array 'total_tax' => $this->total_tax ?? null, 'total_fee' => $this->total_fee ?? null, 'locale' => $this->locale ?? null, + 'payment_provider' => $this->payment_provider ?? null, + 'notes' => $this->notes ?? null, ]; } @@ -423,4 +429,26 @@ public function getLocale(): string { return $this->locale; } + + public function setPaymentProvider(?string $payment_provider): self + { + $this->payment_provider = $payment_provider; + return $this; + } + + public function getPaymentProvider(): ?string + { + return $this->payment_provider; + } + + public function setNotes(?string $notes): self + { + $this->notes = $notes; + return $this; + } + + public function getNotes(): ?string + { + return $this->notes; + } } diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index ca6e0c4f24..41d8a4cadd 100644 --- a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php @@ -32,8 +32,8 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const DELETED_AT = 'deleted_at'; final public const TYPE = 'type'; final public const IS_HIDDEN = 'is_hidden'; - final public const START_COLLAPSED = 'start_collapsed'; final public const PRODUCT_TYPE = 'product_type'; + final public const START_COLLAPSED = 'start_collapsed'; protected int $id; protected int $event_id; @@ -57,8 +57,8 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected ?string $deleted_at = null; protected string $type = 'PAID'; protected ?bool $is_hidden = false; - protected bool $start_collapsed = false; protected string $product_type = 'TICKET'; + protected bool $start_collapsed = false; public function toArray(): array { @@ -85,8 +85,8 @@ public function toArray(): array 'deleted_at' => $this->deleted_at ?? null, 'type' => $this->type ?? null, 'is_hidden' => $this->is_hidden ?? null, - 'start_collapsed' => $this->start_collapsed ?? null, 'product_type' => $this->product_type ?? null, + 'start_collapsed' => $this->start_collapsed ?? null, ]; } @@ -332,25 +332,25 @@ public function getIsHidden(): ?bool return $this->is_hidden; } - public function setStartCollapsed(bool $start_collapsed): self + public function setProductType(string $product_type): self { - $this->start_collapsed = $start_collapsed; + $this->product_type = $product_type; return $this; } - public function getStartCollapsed(): bool + public function getProductType(): string { - return $this->start_collapsed; + return $this->product_type; } - public function setProductType(string $product_type): self + public function setStartCollapsed(bool $start_collapsed): self { - $this->product_type = $product_type; + $this->start_collapsed = $start_collapsed; return $this; } - public function getProductType(): string + public function getStartCollapsed(): bool { - return $this->product_type; + return $this->start_collapsed; } } diff --git a/backend/app/DomainObjects/InvoiceDomainObject.php b/backend/app/DomainObjects/InvoiceDomainObject.php new file mode 100644 index 0000000000..65af110ff7 --- /dev/null +++ b/backend/app/DomainObjects/InvoiceDomainObject.php @@ -0,0 +1,34 @@ +order; + } + + public function setOrder(?OrderDomainObject $order): self + { + $this->order = $order; + + return $this; + } + + public function getEvent(): ?EventDomainObject + { + return $this->event; + } + + public function setEvent(?EventDomainObject $event): self + { + $this->event = $event; + + return $this; + } +} diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php index b196bb5e72..9a5baed0fd 100644 --- a/backend/app/DomainObjects/OrderDomainObject.php +++ b/backend/app/DomainObjects/OrderDomainObject.php @@ -3,13 +3,16 @@ namespace HiEvents\DomainObjects; use HiEvents\DomainObjects\Enums\ProductType; +use HiEvents\DomainObjects\Interfaces\IsFilterable; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\Helper\AddressHelper; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements IsSortable +class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements IsSortable, IsFilterable { /** @var Collection|null */ public ?Collection $orderItems = null; @@ -22,8 +25,26 @@ class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements I /** @var Collection|null */ public ?Collection $questionAndAnswerViews = null; + public ?Collection $invoices = null; + public ?EventDomainObject $event = null; + public static function getAllowedFilterFields(): array + { + return [ + self::STATUS, + self::PAYMENT_STATUS, + self::REFUND_STATUS, + self::CREATED_AT, + self::FIRST_NAME, + self::LAST_NAME, + self::EMAIL, + self::PUBLIC_ID, + self::CURRENCY, + self::TOTAL_GROSS, + ]; + } + public static function getAllowedSorts(): AllowedSorts { return new AllowedSorts( @@ -119,6 +140,11 @@ public function isPaymentRequired(): bool return (int)ceil($this->getTotalGross()) > 0; } + public function isOrderAwaitingOfflinePayment(): bool + { + return $this->getStatus() === OrderStatus::AWAITING_OFFLINE_PAYMENT->name; + } + public function isOrderCompleted(): bool { return $this->getStatus() === OrderStatus::COMPLETED->name; @@ -129,6 +155,16 @@ public function isOrderCancelled(): bool return $this->getStatus() === OrderStatus::CANCELLED->name; } + public function isOrderReserved(): bool + { + return $this->getStatus() === OrderStatus::RESERVED->name; + } + + public function isReservedOrderExpired(): bool + { + return (new Carbon($this->getReservedUntil()))->isPast(); + } + public function isOrderFailed(): bool { return $this->getPaymentStatus() === OrderPaymentStatus::PAYMENT_FAILED->name; @@ -150,6 +186,31 @@ public function isFullyRefunded(): bool return !$this->isFreeOrder() && ($this->getTotalRefunded() >= $this->getTotalGross()); } + public function getHumanReadableStatus(): string + { + return OrderStatus::getHumanReadableStatus($this->getStatus()); + } + + public function getBillingAddressString(): string + { + return AddressHelper::formatAddress($this->getAddress()); + } + + public function getHasTaxes(): bool + { + return $this->getTotalTax() > 0; + } + + public function getHasFees(): bool + { + return $this->getTotalFee() > 0; + } + + public function getLatestInvoice(): ?InvoiceDomainObject + { + return $this->getInvoices()?->sortByDesc(fn(InvoiceDomainObject $invoice) => $invoice->getId())->first(); + } + public function getStripePayment(): ?StripePaymentDomainObject { return $this->stripePayment; @@ -181,4 +242,15 @@ public function getEvent(): ?EventDomainObject { return $this->event; } + + public function setInvoices(?Collection $invoices): OrderDomainObject + { + $this->invoices = $invoices; + return $this; + } + + public function getInvoices(): ?Collection + { + return $this->invoices; + } } diff --git a/backend/app/DomainObjects/Status/AttendeeStatus.php b/backend/app/DomainObjects/Status/AttendeeStatus.php index a41f3bb804..1b668a93f3 100644 --- a/backend/app/DomainObjects/Status/AttendeeStatus.php +++ b/backend/app/DomainObjects/Status/AttendeeStatus.php @@ -9,5 +9,6 @@ enum AttendeeStatus use BaseEnum; case ACTIVE; + case AWAITING_PAYMENT; case CANCELLED; } diff --git a/backend/app/DomainObjects/Status/InvoiceStatus.php b/backend/app/DomainObjects/Status/InvoiceStatus.php new file mode 100644 index 0000000000..1395a266ef --- /dev/null +++ b/backend/app/DomainObjects/Status/InvoiceStatus.php @@ -0,0 +1,14 @@ +name => __('Reserved'), + self::CANCELLED->name => __('Cancelled'), + self::COMPLETED->name => __('Completed'), + self::AWAITING_OFFLINE_PAYMENT->name => __('Awaiting offline payment'), + }; + } } diff --git a/backend/app/Events/OrderStatusChangedEvent.php b/backend/app/Events/OrderStatusChangedEvent.php index e5d9f0b989..2d174f9ef9 100644 --- a/backend/app/Events/OrderStatusChangedEvent.php +++ b/backend/app/Events/OrderStatusChangedEvent.php @@ -12,6 +12,7 @@ class OrderStatusChangedEvent public function __construct( public OrderDomainObject $order, public bool $sendEmails = true, + public bool $createInvoice = false, ) { } diff --git a/backend/app/Exports/OrdersExport.php b/backend/app/Exports/OrdersExport.php index 0073b2b2b9..7eb6b064db 100644 --- a/backend/app/Exports/OrdersExport.php +++ b/backend/app/Exports/OrdersExport.php @@ -43,26 +43,28 @@ public function headings(): array $questionTitles = $this->questions->map(fn($question) => $question->getTitle())->toArray(); return array_merge([ - 'ID', - 'First Name', - 'Last Name', - 'Email', - 'Total Before Additions', - 'Total Gross', - 'Total Tax', - 'Total Fee', - 'Total Refunded', - 'Status', - 'Payment Status', - 'Refund Status', - 'Currency', - 'Created At', - 'Public ID', - 'Payment Gateway', - 'Is Partially Refunded', - 'Is Fully Refunded', - 'Is Free Order', - 'Is Manually Created', + __('ID'), + __('First Name'), + __('Last Name'), + __('Email'), + __('Total Before Additions'), + __('Total Gross'), + __('Total Tax'), + __('Total Fee'), + __('Total Refunded'), + __('Status'), + __('Payment Status'), + __('Refund Status'), + __('Currency'), + __('Created At'), + __('Public ID'), + __('Payment Gateway'), + __('Is Partially Refunded'), + __('Is Fully Refunded'), + __('Is Free Order'), + __('Is Manually Created'), + __('Billing Address'), + __('Notes') ], $questionTitles); } @@ -103,6 +105,8 @@ public function map($order): array $order->isFullyRefunded(), $order->isFreeOrder(), $order->getIsManuallyCreated(), + $order->getBillingAddressString(), + $order->getNotes(), ], $answers->toArray()); } diff --git a/backend/app/Helper/AddressHelper.php b/backend/app/Helper/AddressHelper.php new file mode 100644 index 0000000000..3710426cb9 --- /dev/null +++ b/backend/app/Helper/AddressHelper.php @@ -0,0 +1,28 @@ + !is_null($part) && $part !== ''); + + return implode(', ', $filteredAddressParts); + } +} + diff --git a/backend/app/Helper/Url.php b/backend/app/Helper/Url.php index aff22c5491..eb9c8bd0a6 100644 --- a/backend/app/Helper/Url.php +++ b/backend/app/Helper/Url.php @@ -22,6 +22,13 @@ public static function getFrontEndUrlFromConfig(string $key, array $queryParams return self::addQueryParamsToUrl($queryParams, $url); } + public static function getApiUrl(string $path, array $queryParams = []): string + { + $url = rtrim(config('app.api_url'), '/') . '/' . ltrim($path, '/'); + + return self::addQueryParamsToUrl($queryParams, $url); + } + public static function getCdnUrl(string $path): string { return config('app.cnd_url') . '/' . $path; diff --git a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php index c6f28da3ba..0d3dcf4ef5 100644 --- a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php +++ b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php @@ -5,9 +5,9 @@ use HiEvents\DomainObjects\AttendeeCheckInDomainObject; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; -use HiEvents\DomainObjects\TicketDomainObject; -use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Exports\AttendeesExport; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -40,10 +40,10 @@ public function __invoke(int $eventId): BinaryFileResponse name: 'check_in', )) ->loadRelation(new Relationship( - domainObject: TicketDomainObject::class, + domainObject: ProductDomainObject::class, nested: [ new Relationship( - domainObject: TicketPriceDomainObject::class, + domainObject: ProductPriceDomainObject::class, ), ], name: 'ticket' diff --git a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php index 981ad4f9fc..ae5ea274b3 100644 --- a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php +++ b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php @@ -25,11 +25,11 @@ public function __invoke( ): JsonResponse { try { - $checkIns = $this->createAttendeeCheckInPublicHandler->handle(new CreateAttendeeCheckInPublicDTO( - checkInListUuid: $checkInListUuid, - checkInUserIpAddress: $request->ip(), - attendeePublicIds: $request->validated('attendee_public_ids'), - )); + $checkIns = $this->createAttendeeCheckInPublicHandler->handle(CreateAttendeeCheckInPublicDTO::from([ + 'checkInListUuid' => $checkInListUuid, + 'checkInUserIpAddress' => $request->ip(), + 'attendeesAndActions' => $request->validated('attendees'), + ])); } catch (CannotCheckInException $e) { return $this->errorResponse( message: $e->getMessage(), diff --git a/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php new file mode 100644 index 0000000000..b78a2dc466 --- /dev/null +++ b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $invoice = $this->orderInvoiceHandler->handle(new GetOrderInvoiceDTO( + orderId: $orderId, + eventId: $eventId, + )); + + return $invoice->pdf->stream($invoice->filename); + } +} diff --git a/backend/app/Http/Actions/Orders/EditOrderAction.php b/backend/app/Http/Actions/Orders/EditOrderAction.php new file mode 100644 index 0000000000..9153255c8f --- /dev/null +++ b/backend/app/Http/Actions/Orders/EditOrderAction.php @@ -0,0 +1,36 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $order = $this->handler->handle(new EditOrderDTO( + id: $orderId, + first_name: $request->validated('first_name'), + last_name: $request->validated('last_name'), + email: $request->validated('email'), + notes: $request->validated('notes'), + )); + + return $this->resourceResponse(OrderResource::class, $order); + } + +} diff --git a/backend/app/Http/Actions/Orders/GetOrdersAction.php b/backend/app/Http/Actions/Orders/GetOrdersAction.php index 94824edc72..c8f9575dc9 100644 --- a/backend/app/Http/Actions/Orders/GetOrdersAction.php +++ b/backend/app/Http/Actions/Orders/GetOrdersAction.php @@ -4,10 +4,10 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Resources\Order\OrderResource; use Illuminate\Http\JsonResponse; @@ -29,7 +29,8 @@ public function __invoke(Request $request, int $eventId): JsonResponse $orders = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->loadRelation(AttendeeDomainObject::class) - ->findByEventId($eventId, QueryParamsDTO::fromArray($request->query->all())); + ->loadRelation(InvoiceDomainObject::class) + ->findByEventId($eventId, $this->getPaginationQueryParams($request)); return $this->filterableResourceResponse( resource: OrderResource::class, diff --git a/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php b/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php new file mode 100644 index 0000000000..d9126afe3e --- /dev/null +++ b/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php @@ -0,0 +1,37 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $order = $this->markOrderAsPaidHandler->handle(new MarkOrderAsPaidDTO($eventId, $orderId)); + } catch (ResourceConflictException $e) { + return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: OrderResource::class, + data: $order, + ); + } +} diff --git a/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php similarity index 97% rename from backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php rename to backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php index db9459fd09..68c46439c1 100644 --- a/backend/app/Http/Actions/Orders/CompleteOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Orders; +namespace HiEvents\Http\Actions\Orders\Public; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Http\Actions\BaseAction; diff --git a/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php similarity index 97% rename from backend/app/Http/Actions/Orders/CreateOrderActionPublic.php rename to backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php index 52c9365325..d204081f7d 100644 --- a/backend/app/Http/Actions/Orders/CreateOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace HiEvents\Http\Actions\Orders; +namespace HiEvents\Http\Actions\Orders\Public; use HiEvents\Http\Actions\BaseAction; use HiEvents\Http\Request\Order\CreateOrderRequest; diff --git a/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php new file mode 100644 index 0000000000..686662c63f --- /dev/null +++ b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php @@ -0,0 +1,26 @@ +downloadOrderInvoicePublicHandler->handle( + eventId: $eventId, + orderShortId: $orderShortId, + ); + + return $invoice->pdf->stream($invoice->filename); + } +} diff --git a/backend/app/Http/Actions/Orders/GetOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php similarity index 95% rename from backend/app/Http/Actions/Orders/GetOrderActionPublic.php rename to backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php index 23f8ddce38..01b7e0e925 100644 --- a/backend/app/Http/Actions/Orders/GetOrderActionPublic.php +++ b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php @@ -1,6 +1,6 @@ initializeOrderOfflinePaymentPublicHandler->handle( + TransitionOrderToOfflinePaymentPublicDTO::fromArray([ + 'orderShortId' => $orderShortId, + ]), + ); + + return $this->resourceResponse( + resource: OrderResourcePublic::class, + data: $order, + ); + } +} diff --git a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php index 024aff0f9c..76c08cf9ec 100644 --- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php +++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Http\Actions\BaseAction; @@ -34,6 +35,7 @@ public function __invoke(int $eventId, int $orderId): Response $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) ->findFirstWhere([ OrderDomainObjectAbstract::EVENT_ID => $eventId, OrderDomainObjectAbstract::ID => $orderId, @@ -57,6 +59,7 @@ public function __invoke(int $eventId, int $orderId): Response event: $event, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice(), )); } diff --git a/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php b/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php index a95e73ea7d..06a291d96e 100644 --- a/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php +++ b/backend/app/Http/Request/CheckInList/CreateAttendeeCheckInPublicRequest.php @@ -2,15 +2,18 @@ namespace HiEvents\Http\Request\CheckInList; +use HiEvents\DomainObjects\Enums\AttendeeCheckInActionType; use HiEvents\Http\Request\BaseRequest; +use Illuminate\Validation\Rule; class CreateAttendeeCheckInPublicRequest extends BaseRequest { public function rules(): array { return [ - 'attendee_public_ids' => ['required', 'array'], - 'attendee_public_ids.*' => ['required', 'string'], + 'attendees' => ['required', 'array'], + 'attendees.*.public_id' => ['required', 'string'], + 'attendees.*.action' => ['required', 'string', Rule::in(AttendeeCheckInActionType::valuesArray())], ]; } } diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 5942b7c74c..74416cbaf4 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -3,6 +3,7 @@ namespace HiEvents\Http\Request\EventSettings; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; +use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\PriceDisplayMode; use HiEvents\Http\Request\BaseRequest; use HiEvents\Validators\Rules\RulesHelper; @@ -56,6 +57,24 @@ public function rules(): array 'price_display_mode' => [Rule::in(PriceDisplayMode::valuesArray())], 'hide_getting_started_page' => ['boolean'], + + // Payment settings + 'payment_providers' => ['array'], + 'payment_providers.*' => ['string', Rule::in(PaymentProviders::valuesArray())], + 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn() => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))], + 'allow_orders_awaiting_offline_payment_to_check_in' => ['boolean'], + + // Invoice settings + 'enable_invoicing' => ['boolean'], + 'invoice_label' => ['nullable', 'string', 'max:50'], + 'invoice_prefix' => ['nullable', 'string', 'max:10', 'regex:/^[A-Za-z0-9\-]*$/'], + 'invoice_start_number' => ['nullable', 'integer', 'min:1'], + 'require_billing_address' => ['boolean'], + 'organization_name' => ['required_if:enable_invoicing,true', 'string', 'max:255', 'nullable'], + 'organization_address' => ['required_if:enable_invoicing,true', 'string', 'max:255', 'nullable'], + 'invoice_tax_details' => ['nullable', 'string'], + 'invoice_notes' => ['nullable', 'string'], + 'invoice_payment_terms_days' => ['nullable', 'integer', 'gte:0', 'lte:1000'], ]; } @@ -77,6 +96,16 @@ public function messages(): array 'location_details.country.required_with' => __('The country field is required'), 'location_details.country.max' => __('The country field should be a 2 character ISO 3166 code'), 'price_display_mode.in' => 'The price display mode must be either inclusive or exclusive.', + + // Payment messages + 'payment_providers.*.in' => __('Invalid payment provider selected.'), + 'offline_payment_instructions.required' => __('Payment instructions are required when offline payments are enabled.'), + + // Invoice messages + 'invoice_prefix.regex' => __('The invoice prefix may only contain letters, numbers, and hyphens.'), + 'organization_name.required_if' => __('The organization name is required when invoicing is enabled.'), + 'organization_address.required_if' => __('The organization address is required when invoicing is enabled.'), + 'invoice_start_number.min' => __('The invoice start number must be at least 1.'), ]; } } diff --git a/backend/app/Http/Request/Order/EditOrderRequest.php b/backend/app/Http/Request/Order/EditOrderRequest.php new file mode 100644 index 0000000000..4c291eb405 --- /dev/null +++ b/backend/app/Http/Request/Order/EditOrderRequest.php @@ -0,0 +1,19 @@ + RulesHelper::REQUIRED_EMAIL, + 'first_name' => RulesHelper::REQUIRED_STRING, + 'last_name' => RulesHelper::REQUIRED_STRING, + 'notes' => RulesHelper::OPTIONAL_TEXT_MEDIUM_LENGTH, + ]; + } +} diff --git a/backend/app/Listeners/Order/CreateInvoiceListener.php b/backend/app/Listeners/Order/CreateInvoiceListener.php new file mode 100644 index 0000000000..827cb84f4a --- /dev/null +++ b/backend/app/Listeners/Order/CreateInvoiceListener.php @@ -0,0 +1,33 @@ +createInvoice) { + return; + } + + $order = $event->order; + + if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name && $order->getStatus() !== OrderStatus::COMPLETED->name) { + return; + } + + $this->invoiceCreateService->createInvoiceForOrder($order->getId()); + } +} diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index 722d91ac8a..f3afe2b3ec 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Helper\StringHelper; use HiEvents\Helper\Url; @@ -23,6 +24,7 @@ class AttendeeTicketMail extends BaseMail { public function __construct( + private readonly OrderDomainObject $order, private readonly AttendeeDomainObject $attendee, private readonly EventDomainObject $event, private readonly EventSettingDomainObject $eventSettings, @@ -51,6 +53,7 @@ public function content(): Content 'attendee' => $this->attendee, 'eventSettings' => $this->eventSettings, 'organizer' => $this->organizer, + 'order' => $this->order, 'ticketUrl' => sprintf( Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET), $this->event->getId(), diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php index c04bd78d96..97b041375e 100644 --- a/backend/app/Mail/Order/OrderSummary.php +++ b/backend/app/Mail/Order/OrderSummary.php @@ -2,12 +2,15 @@ namespace HiEvents\Mail\Order; +use Barryvdh\DomPDF\Facade\Pdf; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Helper\Url; use HiEvents\Mail\BaseMail; +use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -21,6 +24,7 @@ public function __construct( private readonly EventDomainObject $event, private readonly OrganizerDomainObject $organizer, private readonly EventSettingDomainObject $eventSettings, + private readonly ?InvoiceDomainObject $invoice, ) { parent::__construct(); @@ -39,6 +43,7 @@ public function content(): Content return new Content( markdown: 'emails.orders.summary', with: [ + 'eventSettings' => $this->eventSettings, 'event' => $this->event, 'order' => $this->order, 'organizer' => $this->organizer, @@ -50,4 +55,26 @@ public function content(): Content ] ); } + + public function attachments(): array + { + if ($this->invoice === null) { + return []; + } + + $invoice = Pdf::loadView('invoice', [ + 'order' => $this->order, + 'event' => $this->event, + 'organizer' => $this->organizer, + 'eventSettings' => $this->eventSettings, + 'invoice' => $this->invoice, + ]); + + return [ + Attachment::fromData( + static fn() => $invoice->output(), + 'invoice.pdf', + )->withMime('application/pdf'), + ]; + } } diff --git a/backend/app/Models/EventSetting.php b/backend/app/Models/EventSetting.php index 33d8085aec..639703bc8b 100644 --- a/backend/app/Models/EventSetting.php +++ b/backend/app/Models/EventSetting.php @@ -8,6 +8,7 @@ protected function getCastMap(): array { return [ 'location_details' => 'array', + 'payment_providers' => 'array', ]; } diff --git a/backend/app/Models/Invoice.php b/backend/app/Models/Invoice.php new file mode 100644 index 0000000000..697bd1be4b --- /dev/null +++ b/backend/app/Models/Invoice.php @@ -0,0 +1,33 @@ + 'array', + 'items' => 'array', + 'total_amount' => 'float', + 'due_date' => 'datetime', + ]; + } + + protected function getFillableFields(): array + { + return []; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/backend/app/Models/Order.php b/backend/app/Models/Order.php index b4b556c9e4..8176461acc 100644 --- a/backend/app/Models/Order.php +++ b/backend/app/Models/Order.php @@ -33,6 +33,11 @@ public function event(): BelongsTo return $this->belongsTo(Event::class); } + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class)->orderBy('created_at', 'desc'); + } + protected function getCastMap(): array { return [ diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 3499b364fb..3706f4ad9b 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Stripe\StripeClient; @@ -29,6 +30,11 @@ public function register(): void */ public function boot(): void { + if ($this->app->environment('local')) { + URL::forceScheme('https'); + URL::forceRootUrl(config('app.url')); + } + if (env('APP_DEBUG') === true && env('APP_LOG_QUERIES') === true && !app()->isProduction()) { DB::listen( static function ($query) { diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 046f41a36b..92f33245c9 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -15,6 +15,7 @@ use HiEvents\Repository\Eloquent\EventSettingsRepository; use HiEvents\Repository\Eloquent\EventStatisticRepository; use HiEvents\Repository\Eloquent\ImageRepository; +use HiEvents\Repository\Eloquent\InvoiceRepository; use HiEvents\Repository\Eloquent\MessageRepository; use HiEvents\Repository\Eloquent\OrderItemRepository; use HiEvents\Repository\Eloquent\OrderRepository; @@ -22,14 +23,14 @@ use HiEvents\Repository\Eloquent\PasswordResetRepository; use HiEvents\Repository\Eloquent\PasswordResetTokenRepository; use HiEvents\Repository\Eloquent\ProductCategoryRepository; +use HiEvents\Repository\Eloquent\ProductPriceRepository; +use HiEvents\Repository\Eloquent\ProductRepository; use HiEvents\Repository\Eloquent\PromoCodeRepository; use HiEvents\Repository\Eloquent\QuestionAnswerRepository; use HiEvents\Repository\Eloquent\QuestionRepository; use HiEvents\Repository\Eloquent\StripeCustomerRepository; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\TaxAndFeeRepository; -use HiEvents\Repository\Eloquent\ProductPriceRepository; -use HiEvents\Repository\Eloquent\ProductRepository; use HiEvents\Repository\Eloquent\UserRepository; use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; @@ -42,6 +43,7 @@ use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; +use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; @@ -49,14 +51,14 @@ use HiEvents\Repository\Interfaces\PasswordResetRepositoryInterface; use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface; use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; +use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Repository\Interfaces\StripeCustomerRepositoryInterface; use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface; use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface; -use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; -use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; use Illuminate\Support\ServiceProvider; @@ -93,6 +95,7 @@ class RepositoryServiceProvider extends ServiceProvider CheckInListRepositoryInterface::class => CheckInListRepository::class, AttendeeCheckInRepositoryInterface::class => AttendeeCheckInRepository::class, ProductCategoryRepositoryInterface::class => ProductCategoryRepository::class, + InvoiceRepositoryInterface::class => InvoiceRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php index e0e16e381e..ed62a37287 100644 --- a/backend/app/Repository/Eloquent/AttendeeRepository.php +++ b/backend/app/Repository/Eloquent/AttendeeRepository.php @@ -72,7 +72,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware $this->model = $this->model->select('attendees.*') ->join('orders', 'orders.id', '=', 'attendees.order_id') - ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name]) + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::CANCELLED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]) ->orderBy( 'attendees.' . ($params->sort_by ?? AttendeeDomainObject::getDefaultSort()), $params->sort_direction ?? 'desc', @@ -111,8 +111,8 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa ->join('product_check_in_lists', 'product_check_in_lists.product_id', '=', 'attendees.product_id') ->join('check_in_lists', 'check_in_lists.id', '=', 'product_check_in_lists.check_in_list_id') ->where('check_in_lists.short_id', $shortId) - ->where('attendees.status', AttendeeStatus::ACTIVE->name) - ->whereIn('orders.status', [OrderStatus::COMPLETED->name]); + ->whereIn('attendees.status',[AttendeeStatus::ACTIVE->name, AttendeeStatus::CANCELLED->name, AttendeeStatus::AWAITING_PAYMENT->name]) + ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]); $this->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_in')); diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php index 1be0bc0534..6fc3f8776c 100644 --- a/backend/app/Repository/Eloquent/BaseRepository.php +++ b/backend/app/Repository/Eloquent/BaseRepository.php @@ -367,17 +367,31 @@ protected function applyFilterFields(QueryParamsDTO $params, array $allowedFilte 'gt' => '>', 'gte' => '>=', 'like' => 'LIKE', + 'in' => 'IN', ]; $operator = $operatorMapping[$filterField->operator] ?? throw new BadMethodCallException( sprintf('Operator %s is not supported', $filterField->operator) ); - $this->model = $this->model->where( - column: $filterField->field, - operator: $operator, - value: $isNull ? null : $filterField->value, - ); + // Special handling for IN operator + if ($operator === 'IN') { + // Ensure value is array or convert comma-separated string to array + $value = is_array($filterField->value) + ? $filterField->value + : explode(',', $filterField->value); + + $this->model = $this->model->whereIn( + column: $filterField->field, + values: $value + ); + } else { + $this->model = $this->model->where( + column: $filterField->field, + operator: $operator, + value: $isNull ? null : $filterField->value, + ); + } }); } } diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php index c3be46d023..e3259a110f 100644 --- a/backend/app/Repository/Eloquent/CheckInListRepository.php +++ b/backend/app/Repository/Eloquent/CheckInListRepository.php @@ -41,7 +41,7 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte JOIN product_check_in_lists tcil ON a.product_id = tcil.product_id WHERE a.deleted_at IS NULL AND tcil.deleted_at IS NULL - AND a.status = 'ACTIVE' + AND a.status in ('ACTIVE', 'AWAITING_PAYMENT') ) SELECT cil.id AS check_in_list_id, diff --git a/backend/app/Repository/Eloquent/InvoiceRepository.php b/backend/app/Repository/Eloquent/InvoiceRepository.php new file mode 100644 index 0000000000..b7a85edb47 --- /dev/null +++ b/backend/app/Repository/Eloquent/InvoiceRepository.php @@ -0,0 +1,42 @@ +model + ->whereHas('order', function ($query) use ($eventId) { + $query->where('event_id', $eventId); + }) + ->orderBy('id', 'desc') + ->first(); + + return $this->handleSingleResult($invoice); + } + + public function findLatestInvoiceForOrder(int $orderId): ?InvoiceDomainObject + { + $invoice = $this->model + ->where('order_id', $orderId) + ->orderBy('id', 'desc') + ->first(); + + return $this->handleSingleResult($invoice); + } +} diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index 43cec90587..d3465506f7 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -43,6 +43,10 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware }; } + if (!empty($params->filter_fields)) { + $this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields()); + } + $this->model = $this->model->orderBy( $params->sort_by ?? OrderDomainObject::getDefaultSort(), $params->sort_direction ?? 'desc', diff --git a/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php b/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php new file mode 100644 index 0000000000..e2091a9fb9 --- /dev/null +++ b/backend/app/Repository/Interfaces/InvoiceRepositoryInterface.php @@ -0,0 +1,16 @@ + + */ +interface InvoiceRepositoryInterface extends RepositoryInterface +{ + public function findLatestInvoiceForEvent(int $eventId): ?InvoiceDomainObject; + + public function findLatestInvoiceForOrder(int $orderId): ?InvoiceDomainObject; +} diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php index ee8b888e92..82eb7a70e0 100644 --- a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php +++ b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php @@ -22,6 +22,7 @@ public function toArray(Request $request): array 'public_id' => $this->getPublicId(), 'product_id' => $this->getProductId(), 'product_price_id' => $this->getProductPriceId(), + 'status' => $this->getStatus(), 'locale' => $this->getLocale(), $this->mergeWhen($this->getCheckIn() !== null, [ 'check_in' => new AttendeeCheckInPublicResource($this->getCheckIn()), diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index b1a62b97c3..84e323d207 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -46,6 +46,23 @@ public function toArray($request): array 'price_display_mode' => $this->getPriceDisplayMode(), 'hide_getting_started_page' => $this->getHideGettingStartedPage(), + + // Payment settings + 'payment_providers' => $this->getPaymentProviders(), + 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), + 'allow_orders_awaiting_offline_payment_to_check_in' => $this->getAllowOrdersAwaitingOfflinePaymentToCheckIn(), + + // Invoice settings + 'enable_invoicing' => $this->getEnableInvoicing(), + 'invoice_label' => $this->getInvoiceLabel(), + 'invoice_prefix' => $this->getInvoicePrefix(), + 'invoice_start_number' => $this->getInvoiceStartNumber(), + 'require_billing_address' => $this->getRequireBillingAddress(), + 'organization_name' => $this->getOrganizationName(), + 'organization_address' => $this->getOrganizationAddress(), + 'invoice_tax_details' => $this->getInvoiceTaxDetails(), + 'invoice_notes' => $this->getInvoiceNotes(), + 'invoice_payment_terms_days' => $this->getInvoicePaymentTermsDays(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 26bfca3fa8..f10f70a422 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -35,6 +35,7 @@ public function toArray($request): array 'support_email' => $this->getSupportEmail(), 'order_timeout_in_minutes' => $this->getOrderTimeoutInMinutes(), + // Homepage settings 'homepage_body_background_color' => $this->getHomepageBodyBackgroundColor(), 'homepage_background_color' => $this->getHomepageBackgroundColor(), 'homepage_primary_color' => $this->getHomepagePrimaryColor(), @@ -49,12 +50,22 @@ public function toArray($request): array 'location_details' => $this->getLocationDetails(), 'is_online_event' => $this->getIsOnlineEvent(), + // SEO settings 'seo_title' => $this->getSeoTitle(), 'seo_description' => $this->getSeoDescription(), 'seo_keywords' => $this->getSeoKeywords(), 'allow_search_engine_indexing' => $this->getAllowSearchEngineIndexing(), 'price_display_mode' => $this->getPriceDisplayMode(), + + // Payment settings + 'payment_providers' => $this->getPaymentProviders(), + 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), + 'allow_orders_awaiting_offline_payment_to_check_in' => $this->getAllowOrdersAwaitingOfflinePaymentToCheckIn(), + + // Invoice settings + 'require_billing_address' => $this->getRequireBillingAddress(), + 'invoice_label' => $this->getInvoiceLabel(), ]; } } diff --git a/backend/app/Resources/Order/Invoice/InvoiceResource.php b/backend/app/Resources/Order/Invoice/InvoiceResource.php new file mode 100644 index 0000000000..e0736c4672 --- /dev/null +++ b/backend/app/Resources/Order/Invoice/InvoiceResource.php @@ -0,0 +1,20 @@ + $this->getId(), + 'invoice_number' => $this->getInvoiceNumber(), + 'order_id' => $this->getOrderId(), + 'status' => $this->getStatus(), + ]; + } +} diff --git a/backend/app/Resources/Order/Invoice/InvoiceResourcePublic.php b/backend/app/Resources/Order/Invoice/InvoiceResourcePublic.php new file mode 100644 index 0000000000..b097141f02 --- /dev/null +++ b/backend/app/Resources/Order/Invoice/InvoiceResourcePublic.php @@ -0,0 +1,20 @@ + $this->getId(), + 'invoice_number' => $this->getInvoiceNumber(), + 'order_id' => $this->getOrderId(), + 'status' => $this->getStatus(), + ]; + } +} diff --git a/backend/app/Resources/Order/OrderResource.php b/backend/app/Resources/Order/OrderResource.php index cec4399210..6f424b4e1a 100644 --- a/backend/app/Resources/Order/OrderResource.php +++ b/backend/app/Resources/Order/OrderResource.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\Resources\Attendee\AttendeeResource; use HiEvents\Resources\BaseResource; +use HiEvents\Resources\Order\Invoice\InvoiceResource; use HiEvents\Resources\Question\QuestionAnswerViewResource; use Illuminate\Http\Request; @@ -38,6 +39,9 @@ public function toArray(Request $request): array 'is_free_order' => $this->isFreeOrder(), 'is_manually_created' => $this->getIsManuallyCreated(), 'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(), + 'address' => $this->getAddress(), + 'notes' => $this->getNotes(), + 'payment_provider' => $this->getPaymentProvider(), 'order_items' => $this->when( !is_null($this->getOrderItems()), fn() => OrderItemResource::collection($this->getOrderItems()) @@ -50,6 +54,10 @@ public function toArray(Request $request): array !is_null($this->getQuestionAndAnswerViews()), fn() => QuestionAnswerViewResource::collection($this->getQuestionAndAnswerViews()), ), + 'latest_invoice' => $this->when( + !is_null($this->getLatestInvoice()), + fn() => (new InvoiceResource($this->getLatestInvoice()))->toArray($request), + ), ]; } } diff --git a/backend/app/Resources/Order/OrderResourcePublic.php b/backend/app/Resources/Order/OrderResourcePublic.php index fbda59e73e..4654c50926 100644 --- a/backend/app/Resources/Order/OrderResourcePublic.php +++ b/backend/app/Resources/Order/OrderResourcePublic.php @@ -8,6 +8,8 @@ use HiEvents\Resources\Attendee\AttendeeResourcePublic; use HiEvents\Resources\BaseResource; use HiEvents\Resources\Event\EventResourcePublic; +use HiEvents\Resources\Order\Invoice\InvoiceResource; +use HiEvents\Resources\Order\Invoice\InvoiceResourcePublic; use Illuminate\Http\Request; /** @@ -46,6 +48,10 @@ public function toArray(Request $request): array includePostCheckoutData: $this->getStatus() === OrderStatus::COMPLETED->name, ), ), + 'latest_invoice' => $this->when( + !is_null($this->getLatestInvoice()), + fn() => (new InvoiceResourcePublic($this->getLatestInvoice()))->toArray($request), + ), 'address' => $this->when( !is_null($this->getAddress()), fn() => $this->getAddress() diff --git a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php index 7f1afabae1..7ef12805ef 100644 --- a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Application\Handlers\Attendee; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Exceptions\ResourceConflictException; @@ -30,10 +31,12 @@ public function __construct( */ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void { - $attendee = $this->attendeeRepository->findFirstWhere([ - 'id' => $resendAttendeeProductDTO->attendeeId, - 'event_id' => $resendAttendeeProductDTO->eventId, - ]); + $attendee = $this->attendeeRepository + ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order')) + ->findFirstWhere([ + 'id' => $resendAttendeeProductDTO->attendeeId, + 'event_id' => $resendAttendeeProductDTO->eventId, + ]); if (!$attendee) { throw new ResourceNotFoundException(); @@ -49,6 +52,7 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void ->findById($resendAttendeeProductDTO->eventId); $this->sendAttendeeProductService->send( + order: $attendee->getOrder(), attendee: $attendee, event: $event, eventSettings: $event->getEventSettings(), diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php index 79283e41df..9acc76bdeb 100644 --- a/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php @@ -26,7 +26,7 @@ public function handle(CreateAttendeeCheckInPublicDTO $checkInData): CreateAtten $checkIns = $this->createAttendeeCheckInService->checkInAttendees( $checkInData->checkInListUuid, $checkInData->checkInUserIpAddress, - $checkInData->attendeePublicIds, + $checkInData->attendeesAndActions, ); $this->logger->info('Attendee check-ins created', [ diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php new file mode 100644 index 0000000000..3f08384c72 --- /dev/null +++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php @@ -0,0 +1,16 @@ +checkInListRepository - ->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event')) + ->loadRelation((new Relationship(domainObject: EventDomainObject::class, nested: [ + new Relationship(domainObject: EventSettingDomainObject::class, name: 'event_settings'), + ], name: 'event'))) ->loadRelation(ProductDomainObject::class) ->findFirstWhere([ 'short_id' => $shortId, diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php index 652fad18eb..70fd94cd80 100644 --- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php @@ -9,13 +9,17 @@ use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO; use HiEvents\Services\Domain\Event\CreateEventService; use HiEvents\Services\Domain\Organizer\OrganizerFetchService; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; +use Illuminate\Database\DatabaseManager; use Throwable; class CreateEventHandler { public function __construct( - private readonly CreateEventService $createEventService, - private readonly OrganizerFetchService $organizerFetchService + private readonly CreateEventService $createEventService, + private readonly OrganizerFetchService $organizerFetchService, + private readonly CreateProductCategoryService $createProductCategoryService, + private readonly DatabaseManager $databaseManager, ) { } @@ -25,6 +29,15 @@ public function __construct( * @throws Throwable */ public function handle(CreateEventDTO $eventData): EventDomainObject + { + return $this->databaseManager->transaction(fn() => $this->createEvent($eventData)); + } + + /** + * @throws OrganizerNotFoundException + * @throws Throwable + */ + private function createEvent(CreateEventDTO $eventData): EventDomainObject { $organizer = $this->organizerFetchService->fetchOrganizer( organizerId: $eventData->organizer_id, @@ -46,6 +59,10 @@ public function handle(CreateEventDTO $eventData): EventDomainObject ->setEventSettings($eventData->event_settings) ->setLocationDetails($eventData->location_details?->toArray()); - return $this->createEventService->createEvent($event); + $newEvent = $this->createEventService->createEvent($event); + + $this->createProductCategoryService->createDefaultProductCategory($newEvent); + + return $newEvent; } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 344596d867..fb7721096f 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -5,6 +5,7 @@ use HiEvents\DataTransferObjects\AddressDTO; use HiEvents\DataTransferObjects\BaseDTO; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; +use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\PriceDisplayMode; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -48,6 +49,23 @@ public function __construct( public readonly ?PriceDisplayMode $price_display_mode = PriceDisplayMode::INCLUSIVE, public readonly ?bool $hide_getting_started_page = false, + + // Payment settings + public readonly array $payment_providers = [], + public readonly ?string $offline_payment_instructions = null, + public readonly bool $allow_orders_awaiting_offline_payment_to_check_in = false, + + // Invoice settings + public readonly bool $enable_invoicing = false, + public readonly ?string $invoice_label = null, + public readonly ?string $invoice_prefix = null, + public readonly ?int $invoice_start_number = null, + public readonly bool $require_billing_address = true, + public readonly ?string $organization_name = null, + public readonly ?string $organization_address = null, + public readonly ?string $invoice_tax_details = null, + public readonly ?string $invoice_notes = null, + public readonly ?int $invoice_payment_terms_days = null, ) { } @@ -87,6 +105,22 @@ public static function createWithDefaults( notify_organizer_of_new_orders: null, price_display_mode: PriceDisplayMode::INCLUSIVE, hide_getting_started_page: false, + + // Payment defaults + payment_providers: [PaymentProviders::STRIPE->value], + offline_payment_instructions: null, + + // Invoice defaults + enable_invoicing: false, + invoice_label: __('Invoice'), + invoice_prefix: null, + invoice_start_number: 1, + require_billing_address: true, + organization_name: $organizer->getName(), + organization_address: null, + invoice_tax_details: null, + invoice_notes: null, + invoice_payment_terms_days: null, ); } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index 76d17c19b7..2e132142fd 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -80,6 +80,40 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'notify_organizer_of_new_orders' => $eventSettingsDTO->settings['notify_organizer_of_new_orders'] ?? $existingSettings->getNotifyOrganizerOfNewOrders(), 'price_display_mode' => $eventSettingsDTO->settings['price_display_mode'] ?? $existingSettings->getPriceDisplayMode(), 'hide_getting_started_page' => $eventSettingsDTO->settings['hide_getting_started_page'] ?? $existingSettings->getHideGettingStartedPage(), + + // Payment settings + 'payment_providers' => $eventSettingsDTO->settings['payment_providers'] ?? $existingSettings->getPaymentProviders(), + 'offline_payment_instructions' => array_key_exists('offline_payment_instructions', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['offline_payment_instructions'] + : $existingSettings->getOfflinePaymentInstructions(), + 'allow_orders_awaiting_offline_payment_to_check_in' => $eventSettingsDTO->settings['allow_orders_awaiting_offline_payment_to_check_in'] + ?? $existingSettings->getAllowOrdersAwaitingOfflinePaymentToCheckIn(), + + // Invoice settings + 'enable_invoicing' => $eventSettingsDTO->settings['enable_invoicing'] ?? $existingSettings->getEnableInvoicing(), + 'invoice_label' => array_key_exists('invoice_label', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_label'] + : $existingSettings->getInvoiceLabel(), + 'invoice_prefix' => array_key_exists('invoice_prefix', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_prefix'] + : $existingSettings->getInvoicePrefix(), + 'invoice_start_number' => $eventSettingsDTO->settings['invoice_start_number'] ?? $existingSettings->getInvoiceStartNumber(), + 'require_billing_address' => $eventSettingsDTO->settings['require_billing_address'] ?? $existingSettings->getRequireBillingAddress(), + 'organization_name' => array_key_exists('organization_name', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['organization_name'] + : $existingSettings->getOrganizationName(), + 'organization_address' => array_key_exists('organization_address', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['organization_address'] + : $existingSettings->getOrganizationAddress(), + 'invoice_tax_details' => array_key_exists('invoice_tax_details', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_tax_details'] + : $existingSettings->getInvoiceTaxDetails(), + 'invoice_notes' => array_key_exists('invoice_notes', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_notes'] + : $existingSettings->getInvoiceNotes(), + 'invoice_payment_terms_days' => array_key_exists('invoice_payment_terms_days', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['invoice_payment_terms_days'] + : $existingSettings->getInvoicePaymentTermsDays() ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 5ed2b24bdf..190d2fb612 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -60,6 +60,24 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'notify_organizer_of_new_orders' => $settings->notify_organizer_of_new_orders, 'price_display_mode' => $settings->price_display_mode->name, 'hide_getting_started_page' => $settings->hide_getting_started_page, + + // Payment settings + 'payment_providers' => $settings->payment_providers, + 'offline_payment_instructions' => $settings->offline_payment_instructions + ?? $this->purifier->purify($settings->offline_payment_instructions), + 'allow_orders_awaiting_offline_payment_to_check_in' => $settings->allow_orders_awaiting_offline_payment_to_check_in, + + // Invoice settings + 'enable_invoicing' => $settings->enable_invoicing, + 'invoice_label' => trim($settings->invoice_label), + 'invoice_prefix' => trim($settings->invoice_prefix), + 'invoice_start_number' => $settings->invoice_start_number, + 'require_billing_address' => $settings->require_billing_address, + 'organization_name' => trim($settings->organization_name), + 'organization_address' => $this->purifier->purify($settings->organization_address), + 'invoice_tax_details' => $this->purifier->purify($settings->invoice_tax_details), + 'invoice_notes' => $this->purifier->purify($settings->invoice_notes), + 'invoice_payment_terms_days' => $settings->invoice_payment_terms_days, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index fd26109e33..226beac4e9 100644 --- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -126,7 +126,9 @@ private function createAttendees(Collection $orderProducts, OrderDomainObject $o AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(), AttendeeDomainObjectAbstract::PRODUCT_ID => $productId, AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendee->product_price_id, - AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::ACTIVE->name, + AttendeeDomainObjectAbstract::STATUS => $order->isPaymentRequired() + ? AttendeeStatus::AWAITING_PAYMENT->name + : AttendeeStatus::ACTIVE->name, AttendeeDomainObjectAbstract::EMAIL => $attendee->email, AttendeeDomainObjectAbstract::FIRST_NAME => $attendee->first_name, AttendeeDomainObjectAbstract::LAST_NAME => $attendee->last_name, @@ -262,6 +264,7 @@ private function updateOrder(OrderDomainObject $order, CompleteOrderOrderDTO $or ->updateFromArray( $order->getId(), [ + OrderDomainObjectAbstract::ADDRESS => $orderDTO->address, OrderDomainObjectAbstract::FIRST_NAME => $orderDTO->first_name, OrderDomainObjectAbstract::LAST_NAME => $orderDTO->last_name, OrderDomainObjectAbstract::EMAIL => $orderDTO->email, diff --git a/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php new file mode 100644 index 0000000000..d9a25aa639 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php @@ -0,0 +1,18 @@ +logger->info(__('Editing order with ID: :id', [ + 'id' => $dto->id, + ])); + + return $this->editOrderService->editOrder( + id: $dto->id, + first_name: $dto->first_name, + last_name: $dto->last_name, + email: $dto->email, + notes: $dto->notes + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php new file mode 100644 index 0000000000..bf4c8bfe97 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php @@ -0,0 +1,24 @@ +generateOrderInvoicePDFService->generatePdfFromOrderId( + orderId: $command->orderId, + eventId: $command->eventId, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php index 3ee1bdcf1a..ddda2fc502 100644 --- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php +++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php @@ -9,6 +9,7 @@ use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -72,6 +73,7 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo ) ], )) + ->loadRelation(new Relationship(domainObject: InvoiceDomainObject::class)) ->loadRelation(new Relationship( domainObject: OrderItemDomainObject::class, )); diff --git a/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php b/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php new file mode 100644 index 0000000000..98aa18d7d5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php @@ -0,0 +1,36 @@ +logger->info(__('Marking order as paid'), [ + 'orderId' => $dto->orderId, + 'eventId' => $dto->eventId, + ]); + + return $this->markOrderAsPaidService->markOrderAsPaid( + $dto->orderId, + $dto->eventId, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php b/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php new file mode 100644 index 0000000000..36c2696714 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php @@ -0,0 +1,23 @@ +generateOrderInvoicePDFService->generatePdfFromOrderShortId( + orderShortId: $orderShortId, + eventId: $eventId, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php new file mode 100644 index 0000000000..85d6630375 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php @@ -0,0 +1,96 @@ +databaseManager->transaction(function () use ($dto) { + /** @var OrderDomainObjectAbstract $order */ + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findByShortId($dto->orderShortId); + + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $order->getEventId(), + ]); + + $this->validateOfflinePayment($order, $eventSettings); + + $this->updateOrderStatuses($order->getId()); + + $this->productQuantityUpdateService->updateQuantitiesFromOrder($order); + + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($order->getId()); + + event(new OrderStatusChangedEvent( + order: $order, + sendEmails: true, + createInvoice: $eventSettings->getEnableInvoicing(), + )); + + return $order; + }); + } + + private function updateOrderStatuses(int $orderId): void + { + $this->orderRepository + ->updateFromArray($orderId, [ + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name, + OrderDomainObjectAbstract::STATUS => OrderStatus::AWAITING_OFFLINE_PAYMENT->name, + OrderDomainObjectAbstract::PAYMENT_PROVIDER => PaymentProviders::OFFLINE->value, + ]); + } + + /** + * @throws ResourceConflictException + */ + public function validateOfflinePayment( + OrderDomainObject $order, + EventSettingDomainObject $settings, + ): void + { + if (!$order->isOrderReserved()) { + throw new ResourceConflictException(__('Order is not in the correct status to transition to offline payment')); + } + + if ($order->isReservedOrderExpired()) { + throw new ResourceConflictException(__('Order reservation has expired')); + } + + if (collect($settings->getPaymentProviders())->contains(PaymentProviders::OFFLINE->value) === false) { + throw new UnauthorizedException(__('Offline payments are not enabled for this event')); + } + } +} diff --git a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php index 73784755b2..30ffc4008c 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php @@ -16,12 +16,15 @@ public function __construct( public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObject { - return $this->productCategoryService->createCategory( - name: $dto->name, - isHidden: $dto->is_hidden, - eventId: $dto->event_id, - description: $dto->description, - noProductsMessage: $dto->no_products_message ?? __('There are no products available in this category'), - ); + $productCategory = new ProductCategoryDomainObject(); + $productCategory->setName($dto->name); + $productCategory->setIsHidden($dto->is_hidden); + $productCategory->setEventId($dto->event_id); + $productCategory->setDescription($dto->description); + $productCategory->setNoProductsMessage( + $dto->no_products_message ?? __('There are no products available in this category' + )); + + return $this->productCategoryService->createCategory($productCategory); } } diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index 7b45c5b903..78286da8e9 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Mail\Attendee\AttendeeTicketMail; use Illuminate\Contracts\Mail\Mailer; @@ -18,6 +19,7 @@ public function __construct( } public function send( + OrderDomainObject $order, AttendeeDomainObject $attendee, EventDomainObject $event, EventSettingDomainObject $eventSettings, @@ -28,6 +30,7 @@ public function send( ->to($attendee->getEmail()) ->locale($attendee->getLocale()) ->send(new AttendeeTicketMail( + order: $order, attendee: $attendee, event: $event, eventSettings: $eventSettings, diff --git a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php index f80a8c3c7b..840a3bd7c1 100644 --- a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php +++ b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php @@ -47,9 +47,9 @@ public function verifyAttendeeBelongsToCheckInList( * * @throws CannotCheckInException */ - public function getAttendees(array $attendeePublicIds): Collection + public function getAttendees(Collection $attendeePublicIds): Collection { - $attendeePublicIds = array_unique($attendeePublicIds); + $attendeePublicIds = array_unique($attendeePublicIds->toArray()); $attendees = $this->attendeeRepository->findWhereIn( field: AttendeeDomainObjectAbstract::PUBLIC_ID, @@ -59,8 +59,8 @@ public function getAttendees(array $attendeePublicIds): Collection if (count($attendees) !== count($attendeePublicIds)) { throw new CannotCheckInException(__('Invalid attendee code detected: :attendees ', [ 'attendees' => implode(', ', array_diff( - $attendeePublicIds, - $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray()) + $attendeePublicIds, + $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray()) ), ])); } diff --git a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php index 127ab15767..a81a0f7e5e 100644 --- a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php +++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php @@ -6,92 +6,155 @@ use HiEvents\DataTransferObjects\ErrorBagDTO; use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\CheckInListDomainObject; +use HiEvents\DomainObjects\Enums\AttendeeCheckInActionType; +use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\AttendeeCheckInDomainObjectAbstract; use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\Exceptions\CannotCheckInException; use HiEvents\Helper\DateHelper; use HiEvents\Helper\IdHelper; +use HiEvents\Repository\Eloquent\AttendeeRepository; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; +use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; +use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Services\Application\Handlers\CheckInList\Public\DTO\AttendeeAndActionDTO; +use HiEvents\Services\Domain\CheckInList\DTO\CheckInResultDTO; use HiEvents\Services\Domain\CheckInList\DTO\CreateAttendeeCheckInsResponseDTO; +use HiEvents\Services\Domain\Order\MarkOrderAsPaidService; +use Illuminate\Database\ConnectionInterface; use Illuminate\Support\Collection; +use Throwable; class CreateAttendeeCheckInService { public function __construct( private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository, private readonly CheckInListDataService $checkInListDataService, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly AttendeeRepository $attendeeRepository, + private readonly ConnectionInterface $db, + private readonly MarkOrderAsPaidService $markOrderAsPaidService, ) { } /** + * @param string $checkInListUuid + * @param string $checkInUserIpAddress + * @param Collection $attendeesAndActions + * @return CreateAttendeeCheckInsResponseDTO * @throws CannotCheckInException - * @throws Exception + * @throws Exception|Throwable */ public function checkInAttendees( - string $checkInListUuid, - string $checkInUserIpAddress, - array $attendeePublicIds + string $checkInListUuid, + string $checkInUserIpAddress, + Collection $attendeesAndActions ): CreateAttendeeCheckInsResponseDTO { - $attendees = $this->checkInListDataService->getAttendees($attendeePublicIds); $checkInList = $this->checkInListDataService->getCheckInList($checkInListUuid); - $this->validateCheckInListIsActive($checkInList); - $existingCheckIns = $this->attendeeCheckInRepository->findWhereIn( + $attendees = $this->fetchAttendees($attendeesAndActions); + $eventSettings = $this->fetchEventSettings($checkInList->getEventId()); + $existingCheckIns = $this->fetchExistingCheckIns($attendees, $checkInList->getEventId()); + + return $this->processAttendeeCheckIns( + $attendees, + $attendeesAndActions, + $checkInList, + $eventSettings, + $existingCheckIns, + $checkInUserIpAddress + ); + } + + /** + * @throws CannotCheckInException + */ + private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void + { + if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) { + throw new CannotCheckInException(__('Check-in list has expired')); + } + + if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) { + throw new CannotCheckInException(__('Check-in list is not active yet')); + } + } + + /** + * @param Collection $attendeesAndActions + * @return Collection + * @throws CannotCheckInException + */ + private function fetchAttendees(Collection $attendeesAndActions): Collection + { + $publicIds = $attendeesAndActions->map( + fn(AttendeeAndActionDTO $attendeeAndAction) => $attendeeAndAction->public_id + ); + return $this->checkInListDataService->getAttendees($publicIds); + } + + private function fetchEventSettings(int $eventId): EventSettingDomainObject + { + return $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $eventId, + ]); + } + + /** + * @param Collection $attendees + * @param int $eventId + * @return Collection + * @throws Exception + */ + private function fetchExistingCheckIns(Collection $attendees, int $eventId): Collection + { + $attendeeIds = $attendees->map(fn(AttendeeDomainObject $attendee) => $attendee->getId())->toArray(); + + return $this->attendeeCheckInRepository->findWhereIn( field: AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID, - values: $attendees->filter( - fn(AttendeeDomainObject $attendee) => in_array($attendee->getPublicId(), $attendeePublicIds, true) - )->map( - fn(AttendeeDomainObject $attendee) => $attendee->getId() - )->toArray(), + values: $attendeeIds, additionalWhere: [ - AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + AttendeeCheckInDomainObjectAbstract::EVENT_ID => $eventId, ], ); + } + /** + * @throws Throwable + * @throws CannotCheckInException + */ + private function processAttendeeCheckIns( + Collection $attendees, + Collection $attendeesAndActions, + CheckInListDomainObject $checkInList, + EventSettingDomainObject $eventSettings, + Collection $existingCheckIns, + string $checkInUserIpAddress + ): CreateAttendeeCheckInsResponseDTO + { $errors = new ErrorBagDTO(); $checkIns = new Collection(); foreach ($attendees as $attendee) { - $this->checkInListDataService->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); - - $existingCheckIn = $existingCheckIns->first( - fn($checkIn) => $checkIn->getAttendeeId() === $attendee->getId() + $result = $this->processIndividualCheckIn( + $attendee, + $attendeesAndActions, + $checkInList, + $eventSettings, + $existingCheckIns, + $checkInUserIpAddress ); - if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) { - $errors->addError( - key: $attendee->getPublicId(), - message: __('Attendee :attendee_name\'s product is cancelled', [ - 'attendee_name' => $attendee->getFullName(), - ]) - ); - continue; + if ($result->checkIn) { + $checkIns->push($result->checkIn); } - - if ($existingCheckIn) { - $checkIns->push($existingCheckIn); - $errors->addError( - key: $attendee->getPublicId(), - message: __('Attendee :attendee_name is already checked in', [ - 'attendee_name' => $attendee->getFullName(), - ]) - ); - continue; + if ($result->error) { + $errors->addError($attendee->getPublicId(), $result->error); } - - $checkIns->push( - $this->attendeeCheckInRepository->create([ - AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), - AttendeeCheckInDomainObjectAbstract::CHECK_IN_LIST_ID => $checkInList->getId(), - AttendeeCheckInDomainObjectAbstract::IP_ADDRESS => $checkInUserIpAddress, - AttendeeCheckInDomainObjectAbstract::PRODUCT_ID => $attendee->getProductId(), - AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX), - AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), - ]) - ); } return new CreateAttendeeCheckInsResponseDTO( @@ -101,16 +164,105 @@ public function checkInAttendees( } /** + * @throws Throwable * @throws CannotCheckInException */ - private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void + private function processIndividualCheckIn( + AttendeeDomainObject $attendee, + Collection $attendeesAndActions, + CheckInListDomainObject $checkInList, + EventSettingDomainObject $eventSettings, + Collection $existingCheckIns, + string $checkInUserIpAddress + ): CheckInResultDTO { - if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) { - throw new CannotCheckInException(__('Check-in list has expired')); + $this->checkInListDataService->verifyAttendeeBelongsToCheckInList($checkInList, $attendee); + + $attendeeAction = $attendeesAndActions->first( + fn(AttendeeAndActionDTO $action) => $action->public_id === $attendee->getPublicId() + ); + $checkInAction = $attendeeAction->action; + + if ($existingCheckIn = $this->getExistingCheckIn($existingCheckIns, $attendee)) { + return new CheckInResultDTO( + checkIn: $existingCheckIn, + error: __('Attendee :attendee_name is already checked in', [ + 'attendee_name' => $attendee->getFullName(), + ]) + ); } - if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) { - throw new CannotCheckInException(__('Check-in list is not active yes')); + if ($error = $this->validateAttendeeStatus($attendee, $checkInAction, $eventSettings)) { + return new CheckInResultDTO(error: $error); } + + return $this->db->transaction(function () use ($attendee, $checkInList, $checkInAction, $checkInUserIpAddress) { + $checkIn = $this->createCheckIn($attendee, $checkInList, $checkInUserIpAddress); + + if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN_AND_MARK_ORDER_AS_PAID->value) { + $this->markOrderAsPaidService->markOrderAsPaid( + orderId: $attendee->getOrderId(), + eventId: $attendee->getEventId(), + ); + } + + return new CheckInResultDTO(checkIn: $checkIn); + }); + } + + private function getExistingCheckIn(Collection $existingCheckIns, AttendeeDomainObject $attendee): ?object + { + return $existingCheckIns->first( + fn($checkIn) => $checkIn->getAttendeeId() === $attendee->getId() + ); + } + + private function validateAttendeeStatus( + AttendeeDomainObject $attendee, + AttendeeCheckInActionType $checkInAction, + EventSettingDomainObject $eventSettings + ): ?string + { + $allowAttendeesAwaitingPaymentToCheckIn = $eventSettings->getAllowOrdersAwaitingOfflinePaymentToCheckIn(); + + if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) { + return __('Attendee :attendee_name\'s ticket is cancelled', [ + 'attendee_name' => $attendee->getFullName(), + ]); + } + + if (!$allowAttendeesAwaitingPaymentToCheckIn) { + if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN->value + && $attendee->getStatus() === AttendeeStatus::AWAITING_PAYMENT->name + ) { + return __('Unable to check in as attendee :attendee_name\'s order is awaiting payment', [ + 'attendee_name' => $attendee->getFullName(), + ]); + } + + if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN_AND_MARK_ORDER_AS_PAID->value) { + return __('Attendee :attendee_name\'s order cannot be marked as paid. Please check your event settings', [ + 'attendee_name' => $attendee->getFullName(), + ]); + } + } + + return null; + } + + private function createCheckIn( + AttendeeDomainObject $attendee, + CheckInListDomainObject $checkInList, + string $checkInUserIpAddress + ): object + { + return $this->attendeeCheckInRepository->create([ + AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), + AttendeeCheckInDomainObjectAbstract::CHECK_IN_LIST_ID => $checkInList->getId(), + AttendeeCheckInDomainObjectAbstract::IP_ADDRESS => $checkInUserIpAddress, + AttendeeCheckInDomainObjectAbstract::PRODUCT_ID => $attendee->getProductId(), + AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX), + AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + ]); } } diff --git a/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php b/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php new file mode 100644 index 0000000000..1eea6fc352 --- /dev/null +++ b/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php @@ -0,0 +1,13 @@ +createEventStatistics($event); - $this->createDefaultProductCategory($event); - $this->databaseManager->commit(); return $event; @@ -145,17 +142,18 @@ private function createEventSettings( 'homepage_body_background_color' => '#7a5eb9', 'continue_button_text' => __('Continue'), 'support_email' => $organizer->getEmail(), - ]); - } - private function createDefaultProductCategory(EventDomainObject $event): void - { - $this->createProductCategoryService->createCategory( - name: __('Tickets'), - isHidden: false, - eventId: $event->getId(), - description: null, - noProductsMessage: __('There are no tickets available for this event.'), - ); + 'payment_providers' => [PaymentProviders::STRIPE->value], + 'offline_payment_instructions' => null, + + 'enable_invoicing' => false, + 'invoice_label' => __('Invoice'), + 'invoice_prefix' => 'INV-', + 'invoice_start_number' => 1, + 'require_billing_address' => true, + 'organization_name' => $organizer->getName(), + 'organization_address' => null, + 'invoice_tax_details' => null, + ]); } } diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index cb58643f0b..171a22f578 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -9,20 +9,22 @@ use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\ImageDomainObject; +use HiEvents\DomainObjects\ProductCategoryDomainObject; +use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\DomainObjects\Status\EventStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; -use HiEvents\DomainObjects\ProductDomainObject; -use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ImageRepositoryInterface; use HiEvents\Services\Domain\CapacityAssignment\CreateCapacityAssignmentService; use HiEvents\Services\Domain\CheckInList\CreateCheckInListService; +use HiEvents\Services\Domain\Product\CreateProductService; +use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService; use HiEvents\Services\Domain\PromoCode\CreatePromoCodeService; use HiEvents\Services\Domain\Question\CreateQuestionService; -use HiEvents\Services\Domain\Product\CreateProductService; use HTMLPurifier; use Illuminate\Database\DatabaseManager; use Throwable; @@ -40,6 +42,7 @@ public function __construct( private readonly ImageRepositoryInterface $imageRepository, private readonly DatabaseManager $databaseManager, private readonly HTMLPurifier $purifier, + private readonly CreateProductCategoryService $createProductCategoryService, ) { } @@ -85,7 +88,7 @@ public function duplicateEvent( } if ($duplicateProducts) { - $this->cloneExistingTickets( + $this->cloneExistingProducts( event: $event, newEventId: $newEvent->getId(), duplicateQuestions: $duplicateQuestions, @@ -93,6 +96,8 @@ public function duplicateEvent( duplicateCapacityAssignments: $duplicateCapacityAssignments, duplicateCheckInLists: $duplicateCheckInLists, ); + } else { + $this->createProductCategoryService->createDefaultProductCategory($newEvent); } if ($duplicateEventCoverImage) { @@ -136,29 +141,42 @@ private function cloneExistingEvent(EventDomainObject $event, bool $cloneEventSe /** * @throws Throwable */ - private function cloneExistingTickets( + private function cloneExistingProducts( EventDomainObject $event, int $newEventId, bool $duplicateQuestions, bool $duplicatePromoCodes, bool $duplicateCapacityAssignments, bool $duplicateCheckInLists, - ): array + ): void { - $oldTicketToNewTicketMap = []; + $oldProductToNewProductMap = []; - foreach ($event->getProducts() as $ticket) { - $ticket->setEventId($newEventId); - $newTicket = $this->createProductService->createTicket( - ticket: $ticket, - accountId: $event->getAccountId(), - taxAndFeeIds: $ticket->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(), + $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($event, $newEventId, &$oldProductToNewProductMap) { + $newCategory = $this->createProductCategoryService->createCategory( + (new ProductCategoryDomainObject()) + ->setName($productCategory->getName()) + ->setNoProductsMessage($productCategory->getNoProductsMessage()) + ->setDescription($productCategory->getDescription()) + ->setIsHidden($productCategory->getIsHidden()) + ->setEventId($newEventId), ); - $oldTicketToNewTicketMap[$ticket->getId()] = $newTicket->getId(); - } + + /** @var ProductDomainObject $product */ + foreach ($productCategory->getProducts() as $product) { + $product->setEventId($newEventId); + $product->setProductCategoryId($newCategory->getId()); + $newProduct = $this->createProductService->createProduct( + product: $product, + accountId: $event->getAccountId(), + taxAndFeeIds: $product->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(), + ); + $oldProductToNewProductMap[$product->getId()] = $newProduct->getId(); + } + }); if ($duplicateQuestions) { - $this->clonePerTicketQuestions($event, $newEventId, $oldTicketToNewTicketMap); + $this->clonePerProductQuestions($event, $newEventId, $oldProductToNewProductMap); } if ($duplicatePromoCodes) { @@ -172,17 +190,15 @@ private function cloneExistingTickets( if ($duplicateCheckInLists) { $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap); } - - return $oldProductToNewProductMap; } /** * @throws Throwable */ - private function clonePerTicketQuestions(EventDomainObject $event, int $newEventId, array $oldTicketToNewTicketMap): void + private function clonePerProductQuestions(EventDomainObject $event, int $newEventId, array $oldProductToNewProductMap): void { foreach ($event->getQuestions() as $question) { - if ($question->getBelongsTo() === QuestionBelongsTo::TICKET->name) { + if ($question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name) { $this->createQuestionService->createQuestion( (new QuestionDomainObject()) ->setTitle($question->getTitle()) @@ -193,8 +209,8 @@ private function clonePerTicketQuestions(EventDomainObject $event, int $newEvent ->setOptions($question->getOptions()) ->setIsHidden($question->getIsHidden()), array_map( - static fn(ProductDomainObject $ticket) => $oldTicketToNewTicketMap[$ticket->getId()], - $question->getTickets()?->all(), + static fn(ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()], + $question->getProducts()?->all(), ), ); } @@ -301,9 +317,11 @@ private function getEventWithRelations(string $eventId, string $accountId): Even return $this->eventRepository ->loadRelation(EventSettingDomainObject::class) ->loadRelation( - new Relationship(ProductDomainObject::class, [ - new Relationship(ProductPriceDomainObject::class), - new Relationship(TaxAndFeesDomainObject::class) + new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class, [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ]), ]) ) ->loadRelation(PromoCodeDomainObject::class) diff --git a/backend/app/Services/Domain/Invoice/InvoiceCreateService.php b/backend/app/Services/Domain/Invoice/InvoiceCreateService.php new file mode 100644 index 0000000000..fcf0d945f1 --- /dev/null +++ b/backend/app/Services/Domain/Invoice/InvoiceCreateService.php @@ -0,0 +1,80 @@ +invoiceRepository->findFirstWhere([ + 'order_id' => $orderId, + ]); + + if ($existingInvoice) { + throw new ResourceConflictException(__('Invoice already exists')); + } + + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(new Relationship(EventDomainObject::class, nested: [ + new Relationship(EventSettingDomainObject::class, name: 'event_settings'), + ], name: 'event')) + ->findById($orderId); + + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $order->getEvent()->getEventSettings(); + /** @var EventDomainObject $event */ + $event = $order->getEvent(); + + return $this->invoiceRepository->create([ + 'order_id' => $orderId, + 'account_id' => $event->getAccountId(), + 'invoice_number' => $this->getLatestInvoiceNumber($event->getId(), $eventSettings), + 'items' => collect($order->getOrderItems())->map(fn(OrderItemDomainObject $item) => $item->toArray())->toArray(), + 'taxes_and_fees' => $order->getTaxesAndFeesRollup(), + 'issue_date' => now()->toDateString(), + 'status' => $order->isOrderCompleted() ? InvoiceStatus::PAID->name : InvoiceStatus::UNPAID->name, + 'total_amount' => $order->getTotalGross(), + 'due_date' => $eventSettings->getInvoicePaymentTermsDays() !== null + ? now()->addDays($eventSettings->getInvoicePaymentTermsDays()) + : null + ]); + } + + public function getLatestInvoiceNumber(int $eventId, EventSettingDomainObject $eventSettings): string + { + $latestInvoice = $this->invoiceRepository->findLatestInvoiceForEvent($eventId); + + $startNumber = $eventSettings->getInvoiceStartNumber() ?? 1; + $prefix = $eventSettings->getInvoicePrefix() ?? ''; + + if (!$latestInvoice) { + return $prefix . $startNumber; + } + + $nextInvoiceNumber = (int)preg_replace('/\D+/', '', $latestInvoice->getInvoiceNumber()) + 1; + + return $prefix . $nextInvoiceNumber; + } + +} diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index acf9caf9e6..a4e63b66f9 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; +use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; @@ -23,7 +24,7 @@ public function __construct( private EventRepositoryInterface $eventRepository, private OrderRepositoryInterface $orderRepository, private Mailer $mailer, - private SendAttendeeTicketService $sendAttendeeProductService, + private SendAttendeeTicketService $sendAttendeeTicketService, ) { } @@ -33,6 +34,7 @@ public function sendOrderSummaryAndProductEmails(OrderDomainObject $order): void $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->loadRelation(AttendeeDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) ->findById($order->getId()); $event = $this->eventRepository @@ -40,9 +42,9 @@ public function sendOrderSummaryAndProductEmails(OrderDomainObject $order): void ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->findById($order->getEventId()); - if ($order->isOrderCompleted()) { + if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) { $this->sendOrderSummaryEmails($order, $event); - $this->sendAttendeeProductEmails($order, $event); + $this->sendAttendeeTicketEmails($order, $event); } if ($order->isOrderFailed()) { @@ -57,7 +59,7 @@ public function sendOrderSummaryAndProductEmails(OrderDomainObject $order): void } } - private function sendAttendeeProductEmails(OrderDomainObject $order, EventDomainObject $event): void + private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainObject $event): void { $sentEmails = []; foreach ($order->getAttendees() as $attendee) { @@ -65,7 +67,8 @@ private function sendAttendeeProductEmails(OrderDomainObject $order, EventDomain continue; } - $this->sendAttendeeProductService->send( + $this->sendAttendeeTicketService->send( + order: $order, attendee: $attendee, event: $event, eventSettings: $event->getEventSettings(), @@ -86,6 +89,7 @@ private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObj event: $event, organizer: $event->getOrganizer(), eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice(), )); if ($order->getIsManuallyCreated() || !$event->getEventSettings()->getNotifyOrganizerOfNewOrders()) { diff --git a/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php b/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php new file mode 100644 index 0000000000..0a8d95a672 --- /dev/null +++ b/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php @@ -0,0 +1,15 @@ +orderRepository->updateWhere( + attributes: array_filter([ + 'first_name' => $first_name, + 'last_name' => $last_name, + 'email' => $email, + 'notes' => $notes, + ]), + where: [ + 'id' => $id + ] + ); + + return $this->orderRepository->findById($id); + } +} diff --git a/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php b/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php new file mode 100644 index 0000000000..2073c609a6 --- /dev/null +++ b/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php @@ -0,0 +1,70 @@ +generatePdf([ + 'short_id' => $orderShortId, + 'event_id' => $eventId, + ]); + } + + public function generatePdfFromOrderId(int $orderId, int $eventId): InvoicePdfResponseDTO + { + return $this->generatePdf([ + 'id' => $orderId, + 'event_id' => $eventId, + ]); + } + + private function generatePdf(array $whereCriteria): InvoicePdfResponseDTO + { + $order = $this->orderRepository + ->loadRelation(new Relationship(EventDomainObject::class, nested: [ + new Relationship(OrganizerDomainObject::class, name: 'organizer'), + new Relationship(EventSettingDomainObject::class, name: 'event_settings'), + ], name: 'event')) + ->findFirstWhere($whereCriteria); + + if (!$order) { + throw new ResourceNotFoundException(__('Order not found')); + } + + $invoice = $this->invoiceRepository->findLatestInvoiceForOrder($order->getId()); + + if (!$invoice) { + throw new ResourceNotFoundException(__('Invoice not found')); + } + + return new InvoicePdfResponseDTO( + pdf: Pdf::loadView('invoice', [ + 'order' => $order, + 'event' => $order->getEvent(), + 'organizer' => $order->getEvent()->getOrganizer(), + 'eventSettings' => $order->getEvent()->getEventSettings(), + 'invoice' => $invoice, + ]), + filename: $invoice->getInvoiceNumber() . '.pdf' + ); + } +} diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php new file mode 100644 index 0000000000..102c2664f3 --- /dev/null +++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php @@ -0,0 +1,98 @@ +databaseManager->transaction(function () use ($orderId, $eventId) { + /** @var OrderDomainObject $order */ + $order = $this->orderRepository->findFirstWhere([ + OrderDomainObjectAbstract::ID => $orderId, + OrderDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name) { + throw new ResourceConflictException(__('Order is not awaiting offline payment')); + } + + $this->updateOrderStatus($orderId); + + $this->updateOrderInvoice($orderId); + + $updatedOrder = $this->orderRepository->findById($orderId); + + $this->updateAttendeeStatuses($updatedOrder); + + event(new OrderStatusChangedEvent( + order: $updatedOrder, + sendEmails: false + )); + + return $updatedOrder; + }); + } + + private function updateOrderInvoice(int $orderId): void + { + $invoice = $this->invoiceRepository->findLatestInvoiceForOrder($orderId); + + if ($invoice) { + $this->invoiceRepository->updateFromArray($invoice->getId(), [ + 'status' => InvoiceStatus::PAID->name, + ]); + } + } + + private function updateOrderStatus(int $orderId): void + { + $this->orderRepository->updateFromArray($orderId, [ + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, + ]); + } + + private function updateAttendeeStatuses(OrderDomainObject $updatedOrder): void + { + $this->attendeeRepository->updateWhere( + attributes: [ + 'status' => AttendeeStatus::ACTIVE->name, + ], + where: [ + 'order_id' => $updatedOrder->getId(), + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + ], + ); + } + +} diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php index 11bc97e98c..181aa88c5e 100644 --- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php +++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php @@ -11,12 +11,14 @@ use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrderItemDomainObject; +use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Exceptions\CannotAcceptPaymentException; use HiEvents\Repository\Eloquent\StripePaymentsRepository; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Payment\Stripe\StripeRefundExpiredOrderService; use HiEvents\Services\Domain\Product\ProductQuantityUpdateService; @@ -25,14 +27,15 @@ use Stripe\PaymentIntent; use Throwable; -readonly class PaymentIntentSucceededHandler +class PaymentIntentSucceededHandler { public function __construct( - private OrderRepositoryInterface $orderRepository, - private StripePaymentsRepository $stripePaymentsRepository, - private ProductQuantityUpdateService $quantityUpdateService, - private StripeRefundExpiredOrderService $refundExpiredOrderService, - private DatabaseManager $databaseManager, + private readonly OrderRepositoryInterface $orderRepository, + private readonly StripePaymentsRepository $stripePaymentsRepository, + private readonly ProductQuantityUpdateService $quantityUpdateService, + private readonly StripeRefundExpiredOrderService $refundExpiredOrderService, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly DatabaseManager $databaseManager, ) { } @@ -56,6 +59,8 @@ public function handleEvent(PaymentIntent $paymentIntent): void $updatedOrder = $this->updateOrderStatuses($stripePayment); + $this->updateAttendeeStatuses($updatedOrder); + $this->quantityUpdateService->updateQuantitiesFromOrder($updatedOrder); OrderStatusChangedEvent::dispatch($updatedOrder); @@ -152,4 +157,17 @@ private function validatePaymentAndOrderStatus( $this->handleExpiredOrder($stripePayment, $paymentIntent); } + + private function updateAttendeeStatuses(OrderDomainObject $updatedOrder): void + { + $this->attendeeRepository->updateWhere( + attributes: [ + 'status' => AttendeeStatus::ACTIVE->name, + ], + where: [ + 'order_id' => $updatedOrder->getId(), + 'status' => AttendeeStatus::AWAITING_PAYMENT->name, + ], + ); + } } diff --git a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php index 3dc6a09fae..58b1b0cba4 100644 --- a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php +++ b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php @@ -65,7 +65,7 @@ public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void public function updateQuantitiesFromOrder(OrderDomainObject $order): void { $this->databaseManager->transaction(function () use ($order) { - if (!$order->getOrderItems() === null) { + if ($order->getOrderItems() === null) { throw new InvalidArgumentException(__('Order has no order items')); } diff --git a/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php index dcd4969da9..9ca7b8bc41 100644 --- a/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php +++ b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Domain\ProductCategory; +use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface; @@ -13,21 +14,18 @@ public function __construct( { } - public function createCategory( - string $name, - bool $isHidden, - int $eventId, - ?string $description, - ?string $noProductsMessage, - ): ProductCategoryDomainObject + public function createCategory(ProductCategoryDomainObject $productCategoryDomainObject): ProductCategoryDomainObject { - return $this->productCategoryRepository->create([ - 'name' => $name, - 'description' => $description, - 'is_hidden' => $isHidden, - 'event_id' => $eventId, - 'order' => $this->productCategoryRepository->getNextOrder($eventId), - 'no_products_message' => $noProductsMessage, - ]); + return $this->productCategoryRepository->create(array_filter($productCategoryDomainObject->toArray())); + } + + public function createDefaultProductCategory(EventDomainObject $event): void + { + $this->createCategory((new ProductCategoryDomainObject()) + ->setEventId($event->getId()) + ->setName(__('Tickets')) + ->setIsHidden(false) + ->setNoProductsMessage(__('There are no tickets available for this event')) + ); } } diff --git a/backend/app/Validators/CompleteOrderValidator.php b/backend/app/Validators/CompleteOrderValidator.php index 35029f92ba..948a1b1b49 100644 --- a/backend/app/Validators/CompleteOrderValidator.php +++ b/backend/app/Validators/CompleteOrderValidator.php @@ -5,12 +5,14 @@ namespace HiEvents\Validators; use HiEvents\DomainObjects\Enums\QuestionBelongsTo; +use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Validators\Rules\OrderQuestionRule; @@ -20,9 +22,10 @@ class CompleteOrderValidator extends BaseValidator { public function __construct( - private readonly QuestionRepositoryInterface $questionRepository, - private readonly ProductRepositoryInterface $productRepository, - private readonly Route $route + private readonly QuestionRepositoryInterface $questionRepository, + private readonly ProductRepositoryInterface $productRepository, + private readonly EventSettingsRepositoryInterface $eventSettingsRepository, + private readonly Route $route ) { } @@ -38,9 +41,11 @@ public function rules(): array ->findWhere( [QuestionDomainObjectAbstract::EVENT_ID => $this->route->parameter('event_id')] ); + $orderQuestions = $questions->filter( fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::ORDER->name ); + $productQuestions = $questions->filter( fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name ); @@ -51,12 +56,28 @@ public function rules(): array [ProductDomainObjectAbstract::EVENT_ID => $this->route->parameter('event_id')] ); + /** @var EventSettingDomainObject $eventSettings */ + $eventSettings = $this->eventSettingsRepository->findFirstWhere([ + 'event_id' => $this->route->parameter('event_id'), + ]); + + $addressRules = $eventSettings->getRequireBillingAddress() ? [ + 'order.address' => 'array', + 'order.address.address_line_1' => 'required|string|max:255', + 'order.address.address_line_2' => 'nullable|string|max:255', + 'order.address.city' => 'required|string|max:85', + 'order.address.state_or_region' => 'nullable|string|max:85', + 'order.address.zip_or_postal_code' => 'nullable|string|max:85', + 'order.address.country' => 'required|string|max:2', + ] : []; + return [ 'order.first_name' => ['required', 'string', 'max:40'], 'order.last_name' => ['required', 'string', 'max:40'], 'order.questions' => new OrderQuestionRule($orderQuestions, $products), 'order.email' => 'required|email', 'products' => new ProductQuestionRule($productQuestions, $products), + ...$addressRules ]; } @@ -66,6 +87,10 @@ public function messages(): array 'order.first_name' => __('First name is required'), 'order.last_name' => __('Last name is required'), 'order.email' => __('A valid email is required'), + 'order.address.address_line_1.required' => __('Address line 1 is required'), + 'order.address.city.required' => __('City is required'), + 'order.address.zip_or_postal_code.required' => __('Zip or postal code is required'), + 'order.address.country.required' => __('Country is required'), ]; } } diff --git a/backend/bootstrap/cache/packages.php b/backend/bootstrap/cache/packages.php deleted file mode 100755 index 206aa19710..0000000000 --- a/backend/bootstrap/cache/packages.php +++ /dev/null @@ -1,85 +0,0 @@ - - array ( - 'providers' => - array ( - 0 => 'Druc\\Langscanner\\LangscannerServiceProvider', - ), - ), - 'laravel/sail' => - array ( - 'providers' => - array ( - 0 => 'Laravel\\Sail\\SailServiceProvider', - ), - ), - 'laravel/sanctum' => - array ( - 'providers' => - array ( - 0 => 'Laravel\\Sanctum\\SanctumServiceProvider', - ), - ), - 'laravel/tinker' => - array ( - 'providers' => - array ( - 0 => 'Laravel\\Tinker\\TinkerServiceProvider', - ), - ), - 'maatwebsite/excel' => - array ( - 'providers' => - array ( - 0 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - ), - 'aliases' => - array ( - 'Excel' => 'Maatwebsite\\Excel\\Facades\\Excel', - ), - ), - 'nesbot/carbon' => - array ( - 'providers' => - array ( - 0 => 'Carbon\\Laravel\\ServiceProvider', - ), - ), - 'nunomaduro/collision' => - array ( - 'providers' => - array ( - 0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - ), - ), - 'nunomaduro/termwind' => - array ( - 'providers' => - array ( - 0 => 'Termwind\\Laravel\\TermwindServiceProvider', - ), - ), - 'php-open-source-saver/jwt-auth' => - array ( - 'aliases' => - array ( - 'JWTAuth' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth', - 'JWTFactory' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory', - ), - 'providers' => - array ( - 0 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', - ), - ), - 'spatie/laravel-ignition' => - array ( - 'providers' => - array ( - 0 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - ), - 'aliases' => - array ( - 'Flare' => 'Spatie\\LaravelIgnition\\Facades\\Flare', - ), - ), -); \ No newline at end of file diff --git a/backend/bootstrap/cache/services.php b/backend/bootstrap/cache/services.php deleted file mode 100755 index 80afc4091b..0000000000 --- a/backend/bootstrap/cache/services.php +++ /dev/null @@ -1,265 +0,0 @@ - - array ( - 0 => 'Illuminate\\Auth\\AuthServiceProvider', - 1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 2 => 'Illuminate\\Bus\\BusServiceProvider', - 3 => 'Illuminate\\Cache\\CacheServiceProvider', - 4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 5 => 'Illuminate\\Cookie\\CookieServiceProvider', - 6 => 'Illuminate\\Database\\DatabaseServiceProvider', - 7 => 'Illuminate\\Encryption\\EncryptionServiceProvider', - 8 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', - 9 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', - 10 => 'Illuminate\\Hashing\\HashServiceProvider', - 11 => 'Illuminate\\Mail\\MailServiceProvider', - 12 => 'Illuminate\\Notifications\\NotificationServiceProvider', - 13 => 'Illuminate\\Pagination\\PaginationServiceProvider', - 14 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 15 => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 16 => 'Illuminate\\Queue\\QueueServiceProvider', - 17 => 'Illuminate\\Redis\\RedisServiceProvider', - 18 => 'Illuminate\\Session\\SessionServiceProvider', - 19 => 'Illuminate\\Translation\\TranslationServiceProvider', - 20 => 'Illuminate\\Validation\\ValidationServiceProvider', - 21 => 'Illuminate\\View\\ViewServiceProvider', - 22 => 'Druc\\Langscanner\\LangscannerServiceProvider', - 23 => 'Laravel\\Sail\\SailServiceProvider', - 24 => 'Laravel\\Sanctum\\SanctumServiceProvider', - 25 => 'Laravel\\Tinker\\TinkerServiceProvider', - 26 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - 27 => 'Carbon\\Laravel\\ServiceProvider', - 28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - 29 => 'Termwind\\Laravel\\TermwindServiceProvider', - 30 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', - 31 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - 32 => 'HiEvents\\Providers\\AppServiceProvider', - 33 => 'HiEvents\\Providers\\AuthServiceProvider', - 34 => 'HiEvents\\Providers\\EventServiceProvider', - 35 => 'HiEvents\\Providers\\RouteServiceProvider', - 36 => 'HiEvents\\Providers\\RepositoryServiceProvider', - ), - 'eager' => - array ( - 0 => 'Illuminate\\Auth\\AuthServiceProvider', - 1 => 'Illuminate\\Cookie\\CookieServiceProvider', - 2 => 'Illuminate\\Database\\DatabaseServiceProvider', - 3 => 'Illuminate\\Encryption\\EncryptionServiceProvider', - 4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider', - 5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider', - 6 => 'Illuminate\\Notifications\\NotificationServiceProvider', - 7 => 'Illuminate\\Pagination\\PaginationServiceProvider', - 8 => 'Illuminate\\Session\\SessionServiceProvider', - 9 => 'Illuminate\\View\\ViewServiceProvider', - 10 => 'Druc\\Langscanner\\LangscannerServiceProvider', - 11 => 'Laravel\\Sanctum\\SanctumServiceProvider', - 12 => 'Maatwebsite\\Excel\\ExcelServiceProvider', - 13 => 'Carbon\\Laravel\\ServiceProvider', - 14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', - 15 => 'Termwind\\Laravel\\TermwindServiceProvider', - 16 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider', - 17 => 'Spatie\\LaravelIgnition\\IgnitionServiceProvider', - 18 => 'HiEvents\\Providers\\AppServiceProvider', - 19 => 'HiEvents\\Providers\\AuthServiceProvider', - 20 => 'HiEvents\\Providers\\EventServiceProvider', - 21 => 'HiEvents\\Providers\\RouteServiceProvider', - 22 => 'HiEvents\\Providers\\RepositoryServiceProvider', - ), - 'deferred' => - array ( - 'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider', - 'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', - 'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider', - 'cache' => 'Illuminate\\Cache\\CacheServiceProvider', - 'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider', - 'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider', - 'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider', - 'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider', - 'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', - 'hash' => 'Illuminate\\Hashing\\HashServiceProvider', - 'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider', - 'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider', - 'mailer' => 'Illuminate\\Mail\\MailServiceProvider', - 'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider', - 'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider', - 'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider', - 'queue' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider', - 'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider', - 'redis' => 'Illuminate\\Redis\\RedisServiceProvider', - 'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider', - 'translator' => 'Illuminate\\Translation\\TranslationServiceProvider', - 'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider', - 'validator' => 'Illuminate\\Validation\\ValidationServiceProvider', - 'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider', - 'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider', - 'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider', - 'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider', - 'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider', - ), - 'when' => - array ( - 'Illuminate\\Broadcasting\\BroadcastServiceProvider' => - array ( - ), - 'Illuminate\\Bus\\BusServiceProvider' => - array ( - ), - 'Illuminate\\Cache\\CacheServiceProvider' => - array ( - ), - 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' => - array ( - ), - 'Illuminate\\Hashing\\HashServiceProvider' => - array ( - ), - 'Illuminate\\Mail\\MailServiceProvider' => - array ( - ), - 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' => - array ( - ), - 'Illuminate\\Pipeline\\PipelineServiceProvider' => - array ( - ), - 'Illuminate\\Queue\\QueueServiceProvider' => - array ( - ), - 'Illuminate\\Redis\\RedisServiceProvider' => - array ( - ), - 'Illuminate\\Translation\\TranslationServiceProvider' => - array ( - ), - 'Illuminate\\Validation\\ValidationServiceProvider' => - array ( - ), - 'Laravel\\Sail\\SailServiceProvider' => - array ( - ), - 'Laravel\\Tinker\\TinkerServiceProvider' => - array ( - ), - ), -); \ No newline at end of file diff --git a/backend/composer.json b/backend/composer.json index 64a37c8ca3..789fe82a58 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "ext-intl": "*", + "barryvdh/laravel-dompdf": "^3.0", "brick/money": "^0.8.0", "doctrine/dbal": "^3.6", "druc/laravel-langscanner": "^2.2", @@ -21,6 +22,7 @@ "nette/php-generator": "^4.0", "php-open-source-saver/jwt-auth": "^2.1", "spatie/icalendar-generator": "^2.8", + "spatie/laravel-data": "^4.11", "stripe/stripe-php": "^10.15" }, "require-dev": { diff --git a/backend/composer.lock b/backend/composer.lock index ea65c93a12..27e8749bd0 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,8 +4,816 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "da22882240b65224977eabdf78856ddd", + "content-hash": "e212c5caa1242efee18b5881ffe23120", "packages": [ + { + "name": "amphp/amp", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "138801fb68cfc9c329da8a7b39d01ce7291ee4b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/138801fb68cfc9c329da8a7b39d01ce7291ee4b0", + "reference": "138801fb68cfc9c329da8a7b39d01ce7291ee4b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-05-10T21:37:46+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/daa00f2efdbd71565bf64ffefa89e37542addf93", + "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-02-17T04:49:38+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "166c43737cef1b77782c648a9d9ed11ee0c9859f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/166c43737cef1b77782c648a9d9ed11ee0c9859f", + "reference": "166c43737cef1b77782c648a9d9ed11ee0c9859f", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-12-21T01:15:34+00:00" + }, + { + "name": "amphp/parallel", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parallel.git", + "reference": "5113111de02796a782f5d90767455e7391cca190" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parallel/zipball/5113111de02796a782f5d90767455e7391cca190", + "reference": "5113111de02796a782f5d90767455e7391cca190", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "files": [ + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" + ], + "psr-4": { + "Amp\\Parallel\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", + "keywords": [ + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], + "support": { + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-12-21T01:56:09+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "66c095673aa5b6e689e63b52d19e577459129ab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/66c095673aa5b6e689e63b52d19e577459129ab3", + "reference": "66c095673aa5b6e689e63b52d19e577459129ab3", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-07-04T00:56:47+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, { "name": "aws/aws-crt-php", "version": "v1.2.4", @@ -155,6 +963,83 @@ }, "time": "2024-03-21T18:06:56+00:00" }, + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.0.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "d2b3a158ba6e6c0fbb97208aa37dc764642ce5d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/d2b3a158ba6e6c0fbb97208aa37dc764642ce5d5", + "reference": "d2b3a158ba6e6c0fbb97208aa37dc764642ce5d5", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7.0", + "orchestra/testbench": "^7|^8|^9", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.0.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2024-10-30T10:10:17+00:00" + }, { "name": "brick/math", "version": "0.11.0", @@ -398,25 +1283,69 @@ "versioning" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.0" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2023-08-31T09:50:34+00:00" + }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2023-08-31T09:50:34+00:00" + "time": "2024-04-12T12:12:48+00:00" }, { "name": "dflydev/dot-access-data", @@ -1005,6 +1934,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "baf4084b27c7f4b5b7a221b19a94d11327664eb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/baf4084b27c7f4b5b7a221b19a94d11327664eb8", + "reference": "baf4084b27c7f4b5b7a221b19a94d11327664eb8", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.0.2" + }, + "time": "2024-12-27T20:27:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" + }, + "time": "2024-12-02T14:37:59+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:26:35+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.3.3", @@ -1806,6 +2890,64 @@ ], "time": "2023-12-03T19:50:20+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/framework", "version": "v11.0.8", @@ -2783,47 +3925,202 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.25.1" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.25.1" + }, + "funding": [ + { + "url": "https://ecologi.com/frankdejonge", + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + } + ], + "time": "2024-03-15T19:58:44+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.15.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", + "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.15.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-01-28T23:22:08+00:00" + }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" }, "funding": [ { - "url": "https://ecologi.com/frankdejonge", - "type": "custom" - }, - { - "url": "https://github.com/frankdejonge", + "url": "https://github.com/sponsors/nyamsprod", "type": "github" } ], - "time": "2024-03-15T19:58:44+00:00" + "time": "2024-12-08T08:40:02+00:00" }, { - "name": "league/mime-type-detection", - "version": "1.15.0", + "name": "league/uri-interfaces", + "version": "7.5.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301" + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", "shasum": "" }, "require": { - "ext-fileinfo": "*", - "php": "^7.4 || ^8.0" + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.2", - "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, "autoload": { "psr-4": { - "League\\MimeTypeDetection\\": "src" + "League\\Uri\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2832,26 +4129,45 @@ ], "authors": [ { - "name": "Frank de Jonge", - "email": "info@frankdejonge.nl" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Mime-type detection for Flysystem", + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], "support": { - "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.15.0" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" }, "funding": [ { - "url": "https://github.com/frankdejonge", + "url": "https://github.com/sponsors/nyamsprod", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/league/flysystem", - "type": "tidelift" } ], - "time": "2024-01-28T23:22:08+00:00" + "time": "2024-12-08T08:18:47+00:00" }, { "name": "maatwebsite/excel", @@ -3122,6 +4438,73 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + }, + "time": "2024-03-31T07:05:07+00:00" + }, { "name": "monolog/monolog", "version": "3.5.0", @@ -3878,7 +5261,269 @@ }, "autoload": { "psr-4": { - "PHPOpenSourceSaver\\JWTAuth\\": "src/" + "PHPOpenSourceSaver\\JWTAuth\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sean Tymon", + "email": "tymon148@gmail.com", + "homepage": "https://tymon.xyz", + "role": "Forked package creator | Developer" + }, + { + "name": "Eric Schricker", + "email": "eric.schricker@adiutabyte.de", + "role": "Developer" + }, + { + "name": "Fabio William Conceição", + "email": "messhias@gmail.com", + "role": "Developer" + } + ], + "description": "JSON Web Token Authentication for Laravel and Lumen", + "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", + "keywords": [ + "Authentication", + "JSON Web Token", + "auth", + "jwt", + "laravel" + ], + "support": { + "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", + "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + }, + "time": "2024-03-19T21:38:46+00:00" + }, + { + "name": "phpdocumentor/reflection", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/Reflection.git", + "reference": "bb4dea805a645553d6d989b23dad9f8041f39502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/bb4dea805a645553d6d989b23dad9f8041f39502", + "reference": "bb4dea805a645553d6d989b23dad9f8041f39502", + "shasum": "" + }, + "require": { + "nikic/php-parser": "~4.18 || ^5.0", + "php": "8.1.*|8.2.*|8.3.*|8.4.*", + "phpdocumentor/reflection-common": "^2.1", + "phpdocumentor/reflection-docblock": "^5", + "phpdocumentor/type-resolver": "^1.2", + "symfony/polyfill-php80": "^1.28", + "webmozart/assert": "^1.7" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/coding-standard": "^12.0", + "mikey179/vfsstream": "~1.2", + "mockery/mockery": "~1.6.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^10.0", + "psalm/phar": "^5.24", + "rector/rector": "^1.0.0", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-5.x": "5.3.x-dev", + "dev-6.x": "6.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\": "src/phpDocumentor" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Reflection library to do Static Analysis for PHP Projects", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/Reflection/issues", + "source": "https://github.com/phpDocumentor/Reflection/tree/6.1.0" + }, + "time": "2024-11-22T15:11:54+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "5.6.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^1.7", + "phpstan/phpdoc-parser": "^1.7|^2.0", + "webmozart/assert": "^1.9.1" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" + }, + "time": "2024-12-07T09:39:29+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -3887,36 +5532,16 @@ ], "authors": [ { - "name": "Sean Tymon", - "email": "tymon148@gmail.com", - "homepage": "https://tymon.xyz", - "role": "Forked package creator | Developer" - }, - { - "name": "Eric Schricker", - "email": "eric.schricker@adiutabyte.de", - "role": "Developer" - }, - { - "name": "Fabio William Conceição", - "email": "messhias@gmail.com", - "role": "Developer" + "name": "Mike van Riel", + "email": "me@mikevanriel.com" } ], - "description": "JSON Web Token Authentication for Laravel and Lumen", - "homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth", - "keywords": [ - "Authentication", - "JSON Web Token", - "auth", - "jwt", - "laravel" - ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { - "issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues", - "source": "https://github.com/PHP-Open-Source-Saver/jwt-auth" + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" }, - "time": "2024-03-19T21:38:46+00:00" + "time": "2024-11-09T15:12:26+00:00" }, { "name": "phpoffice/phpspreadsheet", @@ -4098,6 +5723,53 @@ ], "time": "2023-11-12T21:59:55+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/c00d78fb6b29658347f9d37ebe104bffadf36299", + "reference": "c00d78fb6b29658347f9d37ebe104bffadf36299", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.0.0" + }, + "time": "2024-10-13T11:29:49+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -4863,6 +6535,143 @@ ], "time": "2023-11-08T05:53:05+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/25de49af7223ba039f64da4ae9a28ec2d10d0254", + "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.6" + }, + "time": "2023-11-30T05:34:44+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.7.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f414ff953002a9b18e3a116f5e462c56f21237cf", + "reference": "f414ff953002a9b18e3a116f5e462c56f21237cf", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.40" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.7.0" + }, + "time": "2024-10-27T17:38:32+00:00" + }, { "name": "spatie/enum", "version": "3.13.0", @@ -4999,6 +6808,90 @@ }, "time": "2024-05-16T15:11:32+00:00" }, + { + "name": "spatie/laravel-data", + "version": "4.11.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-data.git", + "reference": "df5b58baebae34475ca35338b4e9a131c9e2a8e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/df5b58baebae34475ca35338b4e9a131c9e2a8e0", + "reference": "df5b58baebae34475ca35338b4e9a131c9e2a8e0", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0", + "php": "^8.1", + "phpdocumentor/reflection": "^6.0", + "spatie/laravel-package-tools": "^1.9.0", + "spatie/php-structure-discoverer": "^2.0" + }, + "require-dev": { + "fakerphp/faker": "^1.14", + "friendsofphp/php-cs-fixer": "^3.0", + "inertiajs/inertia-laravel": "^1.2", + "livewire/livewire": "^3.0", + "mockery/mockery": "^1.6", + "nesbot/carbon": "^2.63", + "nunomaduro/larastan": "^2.0", + "orchestra/testbench": "^8.0|^9.0", + "pestphp/pest": "^2.31", + "pestphp/pest-plugin-laravel": "^2.0", + "pestphp/pest-plugin-livewire": "^2.1", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpunit/phpunit": "^10.0", + "spatie/invade": "^1.0", + "spatie/laravel-typescript-transformer": "^2.5", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/test-time": "^1.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\LaravelData\\LaravelDataServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" + } + ], + "description": "Create unified resources and data transfer objects", + "homepage": "https://github.com/spatie/laravel-data", + "keywords": [ + "laravel", + "laravel-data", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-data/issues", + "source": "https://github.com/spatie/laravel-data/tree/4.11.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2024-10-23T07:14:53+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.16.4", @@ -5059,6 +6952,86 @@ ], "time": "2024-03-20T07:29:11+00:00" }, + { + "name": "spatie/php-structure-discoverer", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/php-structure-discoverer.git", + "reference": "42d161298630ede76c61e8a437a06eea2e106f4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/42d161298630ede76c61e8a437a06eea2e106f4c", + "reference": "42d161298630ede76c61e8a437a06eea2e106f4c", + "shasum": "" + }, + "require": { + "amphp/amp": "^v3.0", + "amphp/parallel": "^2.2", + "illuminate/collections": "^10.0|^11.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.4.3", + "symfony/finder": "^6.0|^7.0" + }, + "require-dev": { + "illuminate/console": "^10.0|^11.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0|^8.0", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^7.0|^8.0|^9.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5|^10.0", + "spatie/laravel-ray": "^1.26" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\StructureDiscoverer\\StructureDiscovererServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\StructureDiscoverer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "role": "Developer" + } + ], + "description": "Automatically discover structures within your PHP application", + "homepage": "https://github.com/spatie/php-structure-discoverer", + "keywords": [ + "discover", + "laravel", + "php", + "php-structure-discoverer" + ], + "support": { + "issues": "https://github.com/spatie/php-structure-discoverer/issues", + "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/LaravelAutoDiscoverer", + "type": "github" + } + ], + "time": "2025-01-13T13:15:29+00:00" + }, { "name": "stripe/stripe-php", "version": "v10.21.0", diff --git a/backend/config/app.php b/backend/config/app.php index 86288b806f..e14cbd420c 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -8,6 +8,7 @@ 'reset_password_token_expiry_in_min' => 15, 'frontend_url' => env('APP_FRONTEND_URL', 'http://localhost'), + 'api_url' => env('APP_URL', 'https://localhost:8443'), 'cnd_url' => env('APP_CDN_URL', '/storage'), 'default_timezone' => 'America/Vancouver', 'default_currency_code' => 'USD', diff --git a/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php b/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php new file mode 100644 index 0000000000..da27fea0f0 --- /dev/null +++ b/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php @@ -0,0 +1,42 @@ +boolean('enable_invoicing')->default(false); + $table->string('invoice_label')->nullable(); + $table->string('invoice_prefix')->nullable(); + $table->unsignedInteger('invoice_start_number')->default(1); + $table->boolean('require_billing_address')->default(true); + $table->string('organization_name')->nullable(); + $table->text('organization_address')->nullable(); + $table->text('invoice_tax_details')->nullable(); + $table->json('payment_providers')->nullable(); + $table->text('offline_payment_instructions')->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn([ + 'enable_invoicing', + 'invoice_label', + 'invoice_prefix', + 'invoice_start_number', + 'require_billing_address', + 'organization_name', + 'organization_address', + 'invoice_tax_details', + 'payment_providers', + 'offline_payment_instructions' + ]); + }); + } +}; diff --git a/backend/database/migrations/2025_01_03_010511_create_invoices_table.php b/backend/database/migrations/2025_01_03_010511_create_invoices_table.php new file mode 100644 index 0000000000..d8024b6ab3 --- /dev/null +++ b/backend/database/migrations/2025_01_03_010511_create_invoices_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->unsignedBigInteger('order_id'); + $table->unsignedBigInteger('account_id'); + $table->string('invoice_number', 50); + $table->timestamp('issue_date')->useCurrent(); + $table->timestamp('due_date')->nullable(); + $table->decimal('total_amount', 14, 2); + $table->string('status', 20)->default('PENDING'); + $table->jsonb('items'); + $table->jsonb('taxes_and_fees')->nullable(); + $table->uuid()->default(DB::raw('gen_random_uuid()')); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade'); + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + } +}; diff --git a/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php b/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php new file mode 100644 index 0000000000..f47d681ffa --- /dev/null +++ b/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php @@ -0,0 +1,28 @@ +string('payment_provider')->nullable(); + }); + + DB::table('orders') + ->where('total_gross', '>', 0) + ->whereNull('payment_provider') + ->update(['payment_provider' => PaymentProviders::STRIPE->name]); + } + + public function down(): void + { + Schema::table('orders', static function (Blueprint $table) { + $table->dropColumn('payment_provider'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php b/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php new file mode 100644 index 0000000000..e2724caf76 --- /dev/null +++ b/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php @@ -0,0 +1,23 @@ +boolean('allow_orders_awaiting_offline_payment_to_check_in')->default(false); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn('allow_orders_awaiting_offline_payment_to_check_in'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_19_042750_add_notes_to_orders_table.php b/backend/database/migrations/2025_01_19_042750_add_notes_to_orders_table.php new file mode 100644 index 0000000000..08645adee3 --- /dev/null +++ b/backend/database/migrations/2025_01_19_042750_add_notes_to_orders_table.php @@ -0,0 +1,22 @@ +text('notes')->nullable(); + }); + } + + public function down(): void + { + Schema::table('orders', static function (Blueprint $table) { + $table->dropColumn('notes'); + }); + } +}; diff --git a/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php b/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php new file mode 100644 index 0000000000..fc74c532fa --- /dev/null +++ b/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php @@ -0,0 +1,23 @@ +integer('invoice_payment_terms_days')->nullable(); + $table->text('invoice_notes')->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn('invoice_payment_terms_days'); + $table->dropColumn('invoice_notes'); + }); + } +}; diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index dcad7287a3..74ba4dd2e9 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -1,9 +1,11 @@ @php use HiEvents\Helper\DateHelper; @endphp -@php /** @uses /backend/app/Mail/OrderSummary.php */ @endphp +@php /** @uses \HiEvents\Mail\Order\OrderSummary */ @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp @php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp @php /** @var \HiEvents\DomainObjects\AttendeeDomainObject $attendee */ @endphp +@php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp + @php /** @var string $ticketUrl */ @endphp @php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp @@ -12,6 +14,14 @@

+@if($order->isOrderAwaitingOfflinePayment()) +
+

+{{ __('ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.') }} +

+
+@endif + {{ __('Please find your ticket details below.') }} diff --git a/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php b/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php index dbfa0311d1..273a939195 100644 --- a/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php +++ b/backend/resources/views/emails/orders/organizer/summary-for-organizer.blade.php @@ -12,8 +12,17 @@

+@if($order->isOrderAwaitingOfflinePayment()) +
+

+{{ __('ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.') }} +

+
+@endif + {{ __('Order Amount:') }} {{ Currency::format($order->getTotalGross(), $event->getCurrency()) }}
-{{ __('Order ID:') }} {{ $order->getPublicId() }} +{{ __('Order ID:') }} {{ $order->getPublicId() }}
+{{ __('Order Status:') }} {{ $order->getHumanReadableStatus() }}
diff --git a/backend/resources/views/emails/orders/summary.blade.php b/backend/resources/views/emails/orders/summary.blade.php index 1ffe4918e8..e0b426b790 100644 --- a/backend/resources/views/emails/orders/summary.blade.php +++ b/backend/resources/views/emails/orders/summary.blade.php @@ -1,14 +1,30 @@ @php use Carbon\Carbon; use HiEvents\Helper\Currency; use HiEvents\Helper\DateHelper; @endphp @php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp @php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp @php /** @var string $orderUrl */ @endphp @php /** @see \HiEvents\Mail\Order\OrderSummary */ @endphp # {{ __('Your Order is Confirmed! ') }} 🎉 + +@if($order->isOrderAwaitingOfflinePayment() === false)

{{ __('Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.', ['eventTitle' => $event->getTitle(), 'eventDate' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y'), 'eventTime' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A')]) }}

+@else +
+

+{{ __('Your order is pending payment. Tickets have been issued but will not be valid until payment is received.') }} +

+ +
+

{{ __('Payment Instructions') }}

+{{ __('Please follow the instructions below to complete your payment.') }} +{!! $eventSettings->getOfflinePaymentInstructions() !!} +
+
+@endif

diff --git a/backend/resources/views/invoice.blade.php b/backend/resources/views/invoice.blade.php new file mode 100644 index 0000000000..86aa461ad7 --- /dev/null +++ b/backend/resources/views/invoice.blade.php @@ -0,0 +1,390 @@ +@php use Carbon\Carbon; @endphp +@php use HiEvents\Helper\Currency; @endphp +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp +@php /** @var \HiEvents\DomainObjects\InvoiceDomainObject $invoice */ @endphp + + + + + + + {{ $eventSettings->getInvoiceLabel() ?? __('Invoice') }} #{{ $invoice->getInvoiceNumber() }} + + + +

+

{{ $eventSettings->getInvoiceLabel() ?? __('Invoice') }}

+
+
{{ $eventSettings->getOrganizationName() }}
+
{!! $eventSettings->getOrganizationAddress() !!}
+ @if($eventSettings->getSupportEmail()) +
{{ $eventSettings->getSupportEmail() }}
+ @endif +
+
+ +
+ + + + + @if($invoice->getDueDate()) + + @endif + + +
+ {{ __('Invoice Number') }} + #{{ $invoice->getInvoiceNumber() }} + + {{ __('Date Issued') }} + {{ Carbon::parse($order->getCreatedAt())->format('d/m/Y') }} + + {{ __('Due Date') }} + {{ Carbon::parse($invoice->getDueDate())->format('d/m/Y') }} + + {{ __('Amount Due') }} + {{ Currency::format($order->getTotalGross(), $order->getCurrency()) }} +
+
+ +
+
{{ __('Billed To') }}
+
{{ $order->getFullName() }}
+
{{ $order->getEmail() }}
+ @if($order->getAddress()) +
{{ $order->getBillingAddressString() }}
+ @endif +
+ + + + + + + + + + + + @php $totalDiscount = 0; @endphp + @foreach($invoice->getItems() as $orderItem) + @php + $itemDiscount = 0; + if ($orderItem['price_before_discount']) { + $itemDiscount = ($orderItem['price_before_discount'] - $orderItem['price']) * $orderItem['quantity']; + $totalDiscount += $itemDiscount; + } + @endphp + + + + + + + @endforeach + +
{{ __('DESCRIPTION') }}{{ __('RATE') }}{{ __('QTY') }}{{ __('AMOUNT') }}
+ {{ $orderItem['item_name'] }} + @if(!empty($orderItem['description'])) +
{{ $orderItem['description'] }}
+ @endif +
+ @if($orderItem['price_before_discount']) +
{{ Currency::format($orderItem['price_before_discount'], $order->getCurrency()) }}
+
{{ Currency::format($orderItem['price'], $order->getCurrency()) }}
+ @else + {{ Currency::format($orderItem['price'], $order->getCurrency()) }} + @endif +
{{ $orderItem['quantity'] }} + @if($orderItem['price_before_discount']) +
{{ Currency::format($orderItem['price_before_discount'] * $orderItem['quantity'], $order->getCurrency()) }}
+
{{ Currency::format($orderItem['total_before_additions'], $order->getCurrency()) }}
+ @else + {{ Currency::format($orderItem['total_before_additions'], $order->getCurrency()) }} + @endif +
+ + + + + + + + @if($totalDiscount > 0) + + + + + @endif + + @if($order->getHasTaxes()) + @foreach($order->getTaxesAndFeesRollup()['taxes'] as $tax) + + + + + @endforeach + + + + + @endif + + @if($order->getHasFees()) + @foreach($order->getTaxesAndFeesRollup()['fees'] as $fee) + + + + + @endforeach + + + + + @endif + + + + + +
{{ __('Subtotal') }}{{ Currency::format($order->getTotalBeforeAdditions(), $order->getCurrency()) }}
{{ __('Total Discount') }}-{{ Currency::format($totalDiscount, $order->getCurrency()) }}
{{ $tax['name'] }} ({{ $tax['rate'] }}@if($tax['type'] === 'PERCENTAGE') + % + @else + {{ $order->getCurrency() }} + @endif){{ Currency::format($tax['value'], $order->getCurrency()) }}
{{ __('Total Tax') }}{{ Currency::format($order->getTotalTax(), $order->getCurrency()) }}
{{ $fee['name'] }} ({{ $fee['rate'] }}@if($fee['type'] === 'PERCENTAGE') + % + @else + {{ $order->getCurrency() }} + @endif){{ Currency::format($fee['value'], $order->getCurrency()) }}
{{ __('Total Service Fee') }}{{ Currency::format($order->getTotalFee(), $order->getCurrency()) }}
{{ __('Total Amount') }}{{ Currency::format($order->getTotalGross(), $order->getCurrency()) }}
+ +@if($eventSettings->getInvoiceNotes()) +
+ {!! $eventSettings->getInvoiceNotes() !!} +
+@endif + + + + diff --git a/backend/routes/api.php b/backend/routes/api.php index aec0d8e8ed..93d0be502d 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -54,16 +54,21 @@ use HiEvents\Http\Actions\Messages\GetMessagesAction; use HiEvents\Http\Actions\Messages\SendMessageAction; use HiEvents\Http\Actions\Orders\CancelOrderAction; -use HiEvents\Http\Actions\Orders\CompleteOrderActionPublic; -use HiEvents\Http\Actions\Orders\CreateOrderActionPublic; +use HiEvents\Http\Actions\Orders\DownloadOrderInvoiceAction; +use HiEvents\Http\Actions\Orders\EditOrderAction; use HiEvents\Http\Actions\Orders\ExportOrdersAction; use HiEvents\Http\Actions\Orders\GetOrderAction; -use HiEvents\Http\Actions\Orders\GetOrderActionPublic; use HiEvents\Http\Actions\Orders\GetOrdersAction; +use HiEvents\Http\Actions\Orders\MarkOrderAsPaidAction; use HiEvents\Http\Actions\Orders\MessageOrderAction; use HiEvents\Http\Actions\Orders\Payment\RefundOrderAction; use HiEvents\Http\Actions\Orders\Payment\Stripe\CreatePaymentIntentActionPublic; use HiEvents\Http\Actions\Orders\Payment\Stripe\GetPaymentIntentActionPublic; +use HiEvents\Http\Actions\Orders\Public\CompleteOrderActionPublic; +use HiEvents\Http\Actions\Orders\Public\CreateOrderActionPublic; +use HiEvents\Http\Actions\Orders\Public\DownloadOrderInvoicePublicAction; +use HiEvents\Http\Actions\Orders\Public\GetOrderActionPublic; +use HiEvents\Http\Actions\Orders\Public\TransitionOrderToOfflinePaymentPublicAction; use HiEvents\Http\Actions\Orders\ResendOrderConfirmationAction; use HiEvents\Http\Actions\Organizers\CreateOrganizerAction; use HiEvents\Http\Actions\Organizers\EditOrganizerAction; @@ -75,6 +80,12 @@ use HiEvents\Http\Actions\ProductCategories\EditProductCategoryAction; use HiEvents\Http\Actions\ProductCategories\GetProductCategoriesAction; use HiEvents\Http\Actions\ProductCategories\GetProductCategoryAction; +use HiEvents\Http\Actions\Products\CreateProductAction; +use HiEvents\Http\Actions\Products\DeleteProductAction; +use HiEvents\Http\Actions\Products\EditProductAction; +use HiEvents\Http\Actions\Products\GetProductAction; +use HiEvents\Http\Actions\Products\GetProductsAction; +use HiEvents\Http\Actions\Products\SortProductsAction; use HiEvents\Http\Actions\PromoCodes\CreatePromoCodeAction; use HiEvents\Http\Actions\PromoCodes\DeletePromoCodeAction; use HiEvents\Http\Actions\PromoCodes\GetPromoCodeAction; @@ -93,12 +104,6 @@ use HiEvents\Http\Actions\TaxesAndFees\DeleteTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\EditTaxOrFeeAction; use HiEvents\Http\Actions\TaxesAndFees\GetTaxOrFeeAction; -use HiEvents\Http\Actions\Products\CreateProductAction; -use HiEvents\Http\Actions\Products\DeleteProductAction; -use HiEvents\Http\Actions\Products\EditProductAction; -use HiEvents\Http\Actions\Products\GetProductAction; -use HiEvents\Http\Actions\Products\GetProductsAction; -use HiEvents\Http\Actions\Products\SortProductsAction; use HiEvents\Http\Actions\Users\CancelEmailChangeAction; use HiEvents\Http\Actions\Users\ConfirmEmailAddressAction; use HiEvents\Http\Actions\Users\ConfirmEmailChangeAction; @@ -204,11 +209,14 @@ function (Router $router): void { $router->get('/events/{event_id}/orders', GetOrdersAction::class); $router->get('/events/{event_id}/orders/{order_id}', GetOrderAction::class); + $router->put('/events/{event_id}/orders/{order_id}', EditOrderAction::class); $router->post('/events/{event_id}/orders/{order_id}/message', MessageOrderAction::class); $router->post('/events/{event_id}/orders/{order_id}/refund', RefundOrderAction::class); $router->post('/events/{event_id}/orders/{order_id}/resend_confirmation', ResendOrderConfirmationAction::class); $router->post('/events/{event_id}/orders/{order_id}/cancel', CancelOrderAction::class); + $router->post('/events/{event_id}/orders/{order_id}/mark-as-paid', MarkOrderAsPaidAction::class); $router->post('/events/{event_id}/orders/export', ExportOrdersAction::class); + $router->get('/events/{event_id}/orders/{order_id}/invoice', DownloadOrderInvoiceAction::class); $router->post('/events/{event_id}/questions', CreateQuestionAction::class); $router->put('/events/{event_id}/questions/{question_id}', EditQuestionAction::class); @@ -266,6 +274,8 @@ function (Router $router): void { $router->post('/events/{event_id}/order', CreateOrderActionPublic::class); $router->put('/events/{event_id}/order/{order_short_id}', CompleteOrderActionPublic::class); $router->get('/events/{event_id}/order/{order_short_id}', GetOrderActionPublic::class); + $router->post('/events/{event_id}/order/{order_short_id}/await-offline-payment', TransitionOrderToOfflinePaymentPublicAction::class); + $router->get('/events/{event_id}/order/{order_short_id}/invoice', DownloadOrderInvoicePublicAction::class); // Attendees $router->get('/events/{event_id}/attendees/{attendee_short_id}', GetAttendeeActionPublic::class); diff --git a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php index 3612cbdc7f..18626c11f7 100644 --- a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php @@ -192,6 +192,7 @@ private function createMockOrganizerDomainObject(): OrganizerDomainObject { return Mockery::mock(OrganizerDomainObject::class, function ($mock) { $mock->shouldReceive('getEmail')->andReturn('organizer@example.com'); + $mock->shouldReceive('getName')->andReturn('Organizer Name'); }); } } diff --git a/docker/all-in-one/.env b/docker/all-in-one/.env index a76047ce19..06529757f1 100644 --- a/docker/all-in-one/.env +++ b/docker/all-in-one/.env @@ -1,5 +1,5 @@ # See the README.md file for informaiton on how to generate the JWT_SECRET and APP_KEY -APP_KEY= +APP_KEY JWT_SECRET= # Frontend variables (Always prefixed with VITE_) diff --git a/frontend/package.json b/frontend/package.json index 6ddec80f38..66aac3e00f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,7 +40,7 @@ "@stripe/stripe-js": "^1.54.1", "@tabler/icons-react": "^2.44.0", "@tanstack/react-query": "5.52.2", - "@tiptap/extension-image": "^2.8.0", + "@tiptap/extension-image": "^2.11.2", "@tiptap/extension-link": "^2.1.13", "@tiptap/extension-text-align": "^2.1.13", "@tiptap/extension-underline": "^2.1.13", diff --git a/frontend/src/api/check-in.client.ts b/frontend/src/api/check-in.client.ts index 98765ea492..93003796b0 100644 --- a/frontend/src/api/check-in.client.ts +++ b/frontend/src/api/check-in.client.ts @@ -18,9 +18,14 @@ export const publicCheckInClient = { const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination)); return response.data; }, - createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => { + createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam, action: 'check-in' | 'check-in-and-mark-order-as-paid') => { const response = await publicApi.post>(`/check-in-lists/${checkInListShortId}/check-ins`, { - "attendee_public_ids": [attendeePublicId], + "attendees": [ + { + "public_id": attendeePublicId, + "action": action + } + ] }); return response.data; }, diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts index a639ada6f7..eb80c8001e 100644 --- a/frontend/src/api/order.client.ts +++ b/frontend/src/api/order.client.ts @@ -25,6 +25,12 @@ export interface FinaliseOrderPayload { attendees: AttendeeDetails[], } +export interface EditOrderPayload { + first_name: string, + last_name: string, + email: string, + notes: string, +} export interface ProductPriceQuantityFormValue { price?: number, @@ -43,7 +49,6 @@ export interface ProductFormPayload { session_identifier?: string, } - export interface RefundOrderPayload { amount: number; notify_buyer: boolean; @@ -85,6 +90,24 @@ export const orderClient = { return new Blob([response.data]); }, + + markAsPaid: async (eventId: IdParam, orderId: IdParam) => { + const response = await api.post>('events/' + eventId + '/orders/' + orderId + '/mark-as-paid'); + return response.data; + }, + + downloadInvoice: async (eventId: IdParam, orderId: IdParam): Promise => { + const response = await api.get(`events/${eventId}/orders/${orderId}/invoice`, { + responseType: 'blob', + }); + + return new Blob([response.data]); + }, + + editOrder: async (eventId: IdParam, orderId: IdParam, payload: EditOrderPayload) => { + const response = await api.put>(`events/${eventId}/orders/${orderId}`, payload); + return response.data; + } } export const orderClientPublic = { @@ -118,4 +141,17 @@ export const orderClientPublic = { const response = await publicApi.put>(`events/${eventId}/order/${orderShortId}`, payload); return response.data; }, + + transitionToOfflinePayment: async (eventId: IdParam, orderShortId: IdParam) => { + const response = await publicApi.post>(`events/${eventId}/order/${orderShortId}/await-offline-payment`); + return response.data; + }, + + downloadInvoice: async (eventId: IdParam, orderShortId: IdParam): Promise => { + const response = await publicApi.get(`events/${eventId}/order/${orderShortId}/invoice`, { + responseType: 'blob', + }); + + return new Blob([response.data]); + }, } diff --git a/frontend/src/components/common/Accordion/index.tsx b/frontend/src/components/common/Accordion/index.tsx index 7662a325da..208dd26375 100644 --- a/frontend/src/components/common/Accordion/index.tsx +++ b/frontend/src/components/common/Accordion/index.tsx @@ -8,6 +8,7 @@ export interface AccordionItem { icon?: (props: TablerIconsProps) => JSX.Element; title: string; count?: number; + hidden?: boolean; content: React.ReactNode; } @@ -28,24 +29,26 @@ export const Accordion = ({items, defaultValue}: AccordionProps) => { chevron: classes.accordionChevron }} > - {items.map((item) => ( - - - - {item.icon && } - {item.title} - {item.count !== undefined && ( - - ({item.count}) - - )} - - - - {item.content} - - - ))} + {items + .filter((item) => !item.hidden) + .map((item) => ( + + + + {item.icon && } + {item.title} + {item.count !== undefined && ( + + ({item.count}) + + )} + + + + {item.content} + + + ))} ); }; diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx index fd5cf84de2..990dd7f5ad 100644 --- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx +++ b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx @@ -8,7 +8,7 @@ import {showError} from "../../../utilites/notifications.tsx"; import {t, Trans} from "@lingui/macro"; interface QRScannerComponentProps { - onCheckIn: (attendeePublicId: string, onRequestComplete: (didSucceed: boolean) => void, onFailure: () => void) => void; + onAttendeeScanned: (attendeePublicId: string) => void; onClose: () => void; } @@ -42,7 +42,6 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { localStorage.setItem("qrScannerSoundOn", JSON.stringify(isSoundOn)); }, [isSoundOn]); - useEffect(() => { latestProcessedAttendeeIdsRef.current = processedAttendeeIds; }, [processedAttendeeIds]); @@ -78,9 +77,7 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { showError(t`You already scanned this ticket`); setIsScanFailed(true); - setInterval(function () { - setIsScanFailed(false); - }, 500); + setInterval(() => setIsScanFailed(false), 500); if (isSoundOn && scanErrorAudioRef.current) { scanErrorAudioRef.current.play(); } @@ -90,38 +87,20 @@ export const QRScannerComponent = (props: QRScannerComponentProps) => { if (!isCheckingIn && !alreadyScanned) { setIsCheckingIn(true); - if (isSoundOn && scanInProgressAudioRef.current) { scanInProgressAudioRef.current.play(); } - props.onCheckIn(debouncedAttendeeId, (didSucceed) => { - setIsCheckingIn(false); - setProcessedAttendeeIds(prevIds => [...prevIds, debouncedAttendeeId]); - setCurrentAttendeeId(null); - - if (didSucceed) { - setIsScanSucceeded(true); - setInterval(function () { - setIsScanSucceeded(false); - }, 500); - if (isSoundOn && scanSuccessAudioRef.current) { - scanSuccessAudioRef.current.play(); - } - } else { - setIsScanFailed(true); - setInterval(function () { - setIsScanFailed(false); - }, 500); - if (isSoundOn && scanErrorAudioRef.current) { - scanErrorAudioRef.current.play(); - } - } - }, () => { - setIsCheckingIn(false); - setCurrentAttendeeId(null); - } - ); + props.onAttendeeScanned(debouncedAttendeeId); + setIsCheckingIn(false); + setProcessedAttendeeIds(prevIds => [...prevIds, debouncedAttendeeId]); + setCurrentAttendeeId(null); + + setIsScanSucceeded(true); + setInterval(() => setIsScanSucceeded(false), 500); + if (isSoundOn && scanSuccessAudioRef.current) { + scanSuccessAudioRef.current.play(); + } } } }, [debouncedAttendeeId]); diff --git a/frontend/src/components/common/AttendeeDetails/index.tsx b/frontend/src/components/common/AttendeeDetails/index.tsx index 92becd73d2..b54f882674 100644 --- a/frontend/src/components/common/AttendeeDetails/index.tsx +++ b/frontend/src/components/common/AttendeeDetails/index.tsx @@ -24,14 +24,6 @@ export const AttendeeDetails = ({attendee}: { attendee: Attendee }) => { {attendee.email} -
-
- {t`Status`} -
-
- {attendee.status === 'ACTIVE' ? {t`Active`} : {t`Canceled`}} -
-
{t`Checked In`} diff --git a/frontend/src/components/common/AttendeeStatusBadge/index.tsx b/frontend/src/components/common/AttendeeStatusBadge/index.tsx new file mode 100644 index 0000000000..24d1951d2c --- /dev/null +++ b/frontend/src/components/common/AttendeeStatusBadge/index.tsx @@ -0,0 +1,36 @@ +import {Attendee} from "../../../types.ts"; +import {Badge} from "@mantine/core"; + +interface AttendeeStatusBadgeProps { + attendee: Attendee; + noStyle?: boolean; +} + +export const AttendeeStatusBadge = ({attendee, noStyle = false}: AttendeeStatusBadgeProps) => { + let color; + + switch (attendee.status) { + case 'AWAITING_PAYMENT': + color = 'orange'; + break; + case 'CANCELLED': + color = 'red'; + break; + case 'ACTIVE': + default: + color = 'green'; + break; + } + + const status = attendee.status.replace('_', ' '); + + if (noStyle) { + return {status}; + } + + return ( + + {status} + + ); +}; diff --git a/frontend/src/components/common/AttendeeTable/index.tsx b/frontend/src/components/common/AttendeeTable/index.tsx index acc6b6b8e7..807ab7e714 100644 --- a/frontend/src/components/common/AttendeeTable/index.tsx +++ b/frontend/src/components/common/AttendeeTable/index.tsx @@ -18,6 +18,7 @@ import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; import {useResendAttendeeTicket} from "../../../mutations/useResendAttendeeTicket.ts"; import {ManageAttendeeModal} from "../../modals/ManageAttendeeModal"; import {ActionMenu} from '../ActionMenu'; +import {AttendeeStatusBadge} from "../AttendeeStatusBadge"; interface AttendeeTableProps { attendees: Attendee[]; @@ -149,9 +150,7 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) /> - {attendee.status} + { +export const AttendeeTicket = ({attendee, product, event, hideButtons = false}: AttendeeTicketProps) => { const productPrice = getAttendeeProductPrice(attendee, product); return ( @@ -61,7 +61,14 @@ export const AttendeeProduct = ({attendee, product, event, hideButtons = false}: {t`Cancelled`}
)} + + {attendee.status === 'AWAITING_PAYMENT' && ( +
+ {t`Awaiting Payment`} +
+ )} {attendee.status !== 'CANCELLED' && } +
{!hideButtons && ( @@ -90,4 +97,4 @@ export const AttendeeProduct = ({attendee, product, event, hideButtons = false}: ); -} \ No newline at end of file +} diff --git a/frontend/src/components/common/CheckoutQuestion/index.tsx b/frontend/src/components/common/CheckoutQuestion/index.tsx index 0658f933e9..fecee11580 100644 --- a/frontend/src/components/common/CheckoutQuestion/index.tsx +++ b/frontend/src/components/common/CheckoutQuestion/index.tsx @@ -6,7 +6,6 @@ import countries from "../../../../data/countries.json"; import {InputGroup} from "../InputGroup"; import classes from "./CheckoutQuestion.module.scss"; import {UserGeneratedContent} from "../UserGeneratedContent"; -import {DatePicker} from "@mantine/dates"; interface CheckoutQuestionProps { questions: Question[], diff --git a/frontend/src/components/common/Editor/index.tsx b/frontend/src/components/common/Editor/index.tsx index 58289f8ce7..8081e9f566 100644 --- a/frontend/src/components/common/Editor/index.tsx +++ b/frontend/src/components/common/Editor/index.tsx @@ -15,7 +15,7 @@ import {ImageResize} from "./Extensions/ImageResizeExtension"; interface EditorProps { onChange: (value: string) => void; value: string; - label?: string; + label?: React.ReactNode; description?: string; required?: boolean; className?: string; diff --git a/frontend/src/components/common/FilterModal/index.tsx b/frontend/src/components/common/FilterModal/index.tsx new file mode 100644 index 0000000000..254731aeb4 --- /dev/null +++ b/frontend/src/components/common/FilterModal/index.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import {Button, Group, Modal, MultiSelect, Stack, Text, TextInput} from '@mantine/core'; +import {useDisclosure} from '@mantine/hooks'; +import {IconFilter} from '@tabler/icons-react'; +import {t} from '@lingui/macro'; + +export interface FilterOption { + field: string; + label: string; + type: 'multi-select' | 'date-range' | 'single-select' | 'text'; + options?: { label: string; value: string }[]; +} + +interface FilterValues { + [key: string]: any; +} + +interface FilterModalProps { + filters: FilterOption[]; + activeFilters: FilterValues; + onChange: (values: FilterValues) => void; + onReset?: () => void; + title?: string; +} + +const normalizeFilterValue = (value: any, type: string): any => { + if (value === undefined || value === null) { + return []; + } + + switch (type) { + case 'multi-select': { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === 'string') { + return value.split(',').filter(Boolean).map(item => item.trim()); + } + + if (value?.value) { + if (Array.isArray(value.value)) { + return value.value; + } + return normalizeFilterValue(value.value, type); + } + + return []; + } + + case 'text': { + return value; + } + + default: { + return value; + } + } +}; + +const normalizeFilters = (filters: FilterOption[], values: FilterValues): FilterValues => { + return filters.reduce((acc, filter) => { + return { + ...acc, + [filter.field]: normalizeFilterValue(values[filter.field], filter.type) + }; + }, {}); +}; + +export const FilterModal: React.FC = ({ + filters, + activeFilters, + onChange, + onReset, + title = t`Filters` + }) => { + const [opened, {open, close}] = useDisclosure(false); + const [localFilters, setLocalFilters] = React.useState(() => { + return normalizeFilters(filters, activeFilters); + }); + + React.useEffect(() => { + if (!opened) { + setLocalFilters(normalizeFilters(filters, activeFilters)); + } + }, [activeFilters, filters, opened]); + + const handleSave = () => { + onChange(localFilters); + close(); + }; + + const handleReset = () => { + const emptyFilters = filters.reduce((acc, filter) => { + let emptyValue; + + switch (filter.type) { + case 'multi-select': { + emptyValue = []; + break; + } + case 'text': { + emptyValue = ''; + break; + } + case 'single-select': { + emptyValue = null; + break; + } + case 'date-range': { + emptyValue = {start: null, end: null}; + break; + } + default: { + emptyValue = null; + } + } + + return { + ...acc, + [filter.field]: emptyValue + }; + }, {}); + + if (onReset) { + onReset(); + } + + setLocalFilters(emptyFilters); + onChange(emptyFilters); + close(); + }; + + const handleFilterChange = (field: string, value: any) => { + setLocalFilters(prev => { + return { + ...prev, + [field]: value, + }; + }); + }; + + const renderFilterInput = (filter: FilterOption) => { + const normalizedValue = normalizeFilterValue(localFilters[filter.field], filter.type); + + switch (filter.type) { + case 'multi-select': { + return ( + { + handleFilterChange(filter.field, value || []); + }} + clearable + searchable + w="100%" + style={{marginBottom: 0}} + /> + ); + } + + case 'text': { + return ( + { + handleFilterChange(filter.field, event.currentTarget.value); + }} + w="100%" + /> + ); + } + + default: { + return null; + } + } + }; + + const countActiveFilters = (filterValues: FilterValues, filterOptions: FilterOption[]): number => { + return Object.entries(filterValues).reduce((count, [field, value]) => { + const filterOption = filterOptions.find(f => f.field === field); + + if (!filterOption) { + return count; + } + + const normalizedValue = normalizeFilterValue(value, filterOption.type); + + if (Array.isArray(normalizedValue)) { + if (normalizedValue.length > 0) { + return count + 1; + } + return count; + } + + if (normalizedValue) { + return count + 1; + } + + return count; + }, 0); + }; + + const activeFilterCount = countActiveFilters(activeFilters, filters); + const hasActiveFilters = activeFilterCount > 0; + + return ( + <> + + + + + {filters.length === 0 ? ( + + {t`No filters available`} + + ) : ( + filters.map(filter => { + return ( +
+ {renderFilterInput(filter)} +
+ ); + }) + )} + + + + + +
+
+ + ); +}; diff --git a/frontend/src/components/common/OrderDetails/index.tsx b/frontend/src/components/common/OrderDetails/index.tsx index 5b5b30f8c5..ced5c221ef 100644 --- a/frontend/src/components/common/OrderDetails/index.tsx +++ b/frontend/src/components/common/OrderDetails/index.tsx @@ -6,14 +6,17 @@ import {Card, CardVariant} from "../Card"; import {Event, Order} from "../../../types.ts"; import classes from "./OrderDetails.module.scss"; import {t} from "@lingui/macro"; +import {formatAddress} from "../../../utilites/formatAddress.tsx"; +import React from "react"; -export const OrderDetails = ({order, event, cardVariant = 'lightGray'}: { +export const OrderDetails = ({order, event, cardVariant = 'lightGray', style = {}}: { order: Order, event: Event, - cardVariant?: CardVariant + cardVariant?: CardVariant, + style?: React.CSSProperties }) => { return ( - +
{t`Name`} @@ -66,6 +69,26 @@ export const OrderDetails = ({order, event, cardVariant = 'lightGray'}: {
+ {order.payment_provider && ( +
+
+ {t`Payment provider`} +
+
+ {order.payment_provider} +
+
+ )} + {order.address && ( +
+
+ {t`Address`} +
+
+ {formatAddress(order.address)} +
+
+ )}
); } diff --git a/frontend/src/components/common/OrderStatusBadge/index.tsx b/frontend/src/components/common/OrderStatusBadge/index.tsx index b384b2b1f9..a5d4ba4665 100644 --- a/frontend/src/components/common/OrderStatusBadge/index.tsx +++ b/frontend/src/components/common/OrderStatusBadge/index.tsx @@ -1,12 +1,19 @@ import {Badge, BadgeVariant} from "@mantine/core"; import {Order} from "../../../types.ts"; import {getStatusColor} from "../../../utilites/helpers.ts"; +import {t} from "@lingui/macro"; export const OrderStatusBadge = ({order, variant = 'outline'}: { order: Order, variant?: BadgeVariant }) => { let color; let title; - if (order.refund_status) { + if (order.status === 'CANCELLED') { + color = getStatusColor(order.status); + title = t`Cancelled`; + } else if (order.status === 'AWAITING_OFFLINE_PAYMENT') { + color = getStatusColor('AWAITING_PAYMENT'); + title = t`Awaiting offline payment`; + } else if (order.refund_status) { color = getStatusColor(order.refund_status); title = order.refund_status; } else if (order.payment_status && order.payment_status !== 'PAYMENT_RECEIVED' @@ -19,4 +26,4 @@ export const OrderStatusBadge = ({order, variant = 'outline'}: { order: Order, v } return {title.replace('_', ' ')} -}; \ No newline at end of file +}; diff --git a/frontend/src/components/common/OrdersTable/index.tsx b/frontend/src/components/common/OrdersTable/index.tsx index 6aaef168e0..153479c423 100644 --- a/frontend/src/components/common/OrdersTable/index.tsx +++ b/frontend/src/components/common/OrdersTable/index.tsx @@ -1,18 +1,22 @@ import {t} from "@lingui/macro"; import {Anchor, Badge, Button, Group, Menu, Table as MantineTable, Tooltip} from '@mantine/core'; -import {Event, IdParam, MessageType, Order} from "../../../types.ts"; +import {Event, IdParam, Invoice, MessageType, Order} from "../../../types.ts"; import { + IconBasketCog, + IconCash, IconCheck, IconDotsVertical, IconEye, IconInfoCircle, + IconReceipt2, + IconReceiptDollar, IconReceiptRefund, IconRepeat, IconSend, IconTrash } from "@tabler/icons-react"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; -import {ViewOrderModal} from "../../modals/ViewOrderModal"; +import {ManageOrderModal} from "../../modals/ManageOrderModal"; import {useDisclosure} from "@mantine/hooks"; import {useState} from "react"; import {CancelOrderModal} from "../../modals/CancelOrderModal"; @@ -29,6 +33,10 @@ import {useResendOrderConfirmation} from "../../../mutations/useResendOrderConfi import {OrderStatusBadge} from "../OrderStatusBadge"; import {formatNumber} from "../../../utilites/helpers.ts"; import {useUrlHash} from "../../../hooks/useUrlHash.ts"; +import {useMarkOrderAsPaid} from "../../../mutations/useMarkOrderAsPaid.ts"; +import {orderClient} from "../../../api/order.client.ts"; +import {downloadBinary} from "../../../utilites/download.ts"; +import {showError} from "../../../utilites/notifications.tsx"; interface OrdersTableProps { event: Event, @@ -42,6 +50,7 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { const [isRefundModalOpen, refundModal] = useDisclosure(false); const [orderId, setOrderId] = useState(); const resendConfirmationMutation = useResendOrderConfirmation(); + const markAsPaidMutation = useMarkOrderAsPaid(); useUrlHash(/^#order-(\d+)$/, (matches => { const orderId = matches![1]; @@ -66,6 +75,23 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { modal.open(); } + const handleMarkAsPaid = (eventId: IdParam, orderId: IdParam) => { + markAsPaidMutation.mutate({eventId, orderId}, { + onSuccess: () => { + notifications.show({ + message: t`Order marked as paid`, + icon: + }) + }, + onError: () => { + notifications.show({ + message: t`There was an error marking the order as paid`, + icon: + }) + } + }); + } + const handleResendConfirmation = (eventId: IdParam, orderId: IdParam) => { resendConfirmationMutation.mutate({eventId, orderId}, { onSuccess: () => { @@ -83,6 +109,15 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { }); } + const handleInvoiceDownload = async (invoice: Invoice) => { + try { + const blob = await orderClient.downloadInvoice(event.id, invoice.order_id); + downloadBinary(blob, invoice.invoice_number + '.pdf'); + } catch (error) { + showError(t`Failed to download invoice. Please try again.`); + } + } + const ActionMenu = ({order}: { order: Order }) => { return @@ -108,11 +143,22 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { {t`Manage`} handleModalClick(order.id, viewModal)} - leftSection={}>{t`View order`} + leftSection={}>{t`Manage order`} handleModalClick(order.id, messageModal)} leftSection={}>{t`Message buyer`} - {!order.is_free_order && ( + {order.latest_invoice && ( + handleInvoiceDownload(order.latest_invoice as Invoice)} + leftSection={}>{t`Download invoice`} + )} + + {order.status === 'AWAITING_OFFLINE_PAYMENT' && ( + handleMarkAsPaid(event.id, order.id)} + leftSection={}>{t`Mark as paid`} + )} + + + {!order.is_free_order && order.status !== 'AWAITING_OFFLINE_PAYMENT' && ( handleModalClick(order.id, refundModal)} leftSection={}>{t`Refund order`} )} @@ -249,7 +295,7 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => { {orderId && ( <> {isRefundModalOpen && } - {isViewModalOpen && } + {isViewModalOpen && } {isCancelModalOpen && } {isMessageModalOpen && { }, { number: formatNumber(eventStats?.total_orders as number), - description: t`Orders Created`, + description: t`Completed orders`, icon: , backgroundColor: '#E67D49' // Coral orange } diff --git a/frontend/src/components/common/ToolBar/ToolBar.module.scss b/frontend/src/components/common/ToolBar/ToolBar.module.scss index 44a9add6aa..a6b2081355 100644 --- a/frontend/src/components/common/ToolBar/ToolBar.module.scss +++ b/frontend/src/components/common/ToolBar/ToolBar.module.scss @@ -2,6 +2,7 @@ .card { container-type: inline-size; + margin-bottom: 1rem; .wrapper { display: flex; @@ -12,11 +13,31 @@ @include respond-below(sm, true) { flex-direction: column; align-items: flex-start; + width: 100%; } .searchBar { margin-bottom: 0 !important; width: 100%; + flex: 1; + min-width: 0; // Prevents flex item from overflowing + } + + .filterAndActions { + display: flex; + gap: 10px; + align-items: center; + place-self: flex-end; + + @include respond-below(sm, true) { + width: 100%; + justify-content: flex-end; + } + } + + .filter { + display: flex; + align-items: center; } .actions { @@ -24,10 +45,11 @@ gap: 10px; align-items: center; place-self: flex-end; + flex-wrap: wrap; } } button { height: 42px !important; } -} \ No newline at end of file +} diff --git a/frontend/src/components/common/ToolBar/index.tsx b/frontend/src/components/common/ToolBar/index.tsx index f8c5a61c22..5f8f7dd3e1 100644 --- a/frontend/src/components/common/ToolBar/index.tsx +++ b/frontend/src/components/common/ToolBar/index.tsx @@ -1,23 +1,43 @@ import React from "react"; import {Card} from "../Card"; -import classes from './ToolBar.module.scss' +import classes from './ToolBar.module.scss'; +import {Group} from '@mantine/core'; interface ToolBarProps { - children: React.ReactNode[] | React.ReactNode, - searchComponent?: () => React.ReactNode, + children?: React.ReactNode[] | React.ReactNode; + searchComponent?: () => React.ReactNode; + filterComponent?: React.ReactNode; + className?: string; } -export const ToolBar = ({searchComponent, children}: ToolBarProps) => { +export const ToolBar: React.FC = ({ + searchComponent, + filterComponent, + children, + className, + }) => { return ( - +
- {searchComponent &&
- {searchComponent && searchComponent()} -
} -
- {children} -
+ {searchComponent && ( +
+ {searchComponent()} +
+ )} + + + {filterComponent && ( +
+ {filterComponent} +
+ )} + {children && ( +
+ {children} +
+ )} +
- ) -} + ); +}; diff --git a/frontend/src/components/forms/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx index 5cb9e8bafa..45082760db 100644 --- a/frontend/src/components/forms/ProductForm/index.tsx +++ b/frontend/src/components/forms/ProductForm/index.tsx @@ -251,7 +251,7 @@ export const ProductForm = ({form, product}: ProductFormProps) => { () => Promise) => void +}) { const {eventId, orderShortId} = useParams(); const stripe = useStripe(); const elements = useElements(); const [message, setMessage] = useState(''); - const [isLoading, setIsLoading] = useState(false); const {data: order, isFetched: isOrderFetched} = useGetOrderPublic(eventId, orderShortId, ['event']); const event = order?.event; @@ -54,12 +53,19 @@ export default function StripeCheckoutForm() { }); }, [stripe]); + useEffect(() => { + if (setSubmitHandler) { + setSubmitHandler(() => handleSubmit); + } + + }, [setSubmitHandler, stripe, elements]); + if (!isOrderFetched || !order?.payment_status) { return ( - ) + ); } if (order?.payment_status === 'PAYMENT_RECEIVED') { @@ -82,15 +88,11 @@ export default function StripeCheckoutForm() { ); } - const handleSubmit = async (e: any) => { - e.preventDefault(); - + const handleSubmit = async () => { if (!stripe || !elements) { return; } - setIsLoading(true); - const {error} = await stripe.confirmPayment({ elements, confirmParams: { @@ -98,13 +100,11 @@ export default function StripeCheckoutForm() { }, }); - if (error.type === "card_error" || error.type === "validation_error") { + if (error?.type === "card_error" || error?.type === "validation_error") { setMessage(error.message); } else { setMessage(t`An unexpected error occurred.`); } - - setIsLoading(false); }; const paymentElementOptions: stripeJs.StripePaymentElementOptions = { @@ -113,12 +113,12 @@ export default function StripeCheckoutForm() { defaultCollapsed: false, radios: true, spacedAccordionItems: true, - } - } + }, + }; return ( -
- + + <>

{t`Payment`}

@@ -128,33 +128,12 @@ export default function StripeCheckoutForm() { {message !== '' && {message}} - setIsLoading(false)}/> - -
- {t`Powered -
-
- -
- Place Order -
-
- {formatCurrency(order.total_gross, order.currency)} -
-
- {order.currency} -
- - ) : t`Complete Payment`} - /> + + ); } diff --git a/frontend/src/components/layouts/CheckIn/CheckIn.module.scss b/frontend/src/components/layouts/CheckIn/CheckIn.module.scss index 770f587b42..2436120dfe 100644 --- a/frontend/src/components/layouts/CheckIn/CheckIn.module.scss +++ b/frontend/src/components/layouts/CheckIn/CheckIn.module.scss @@ -80,6 +80,12 @@ .details { flex: 1; + .awaitingPayment { + margin: 3px 0; + color: #e09300; + font-weight: 900; + } + .product { color: #999; font-size: 0.9em; diff --git a/frontend/src/components/layouts/CheckIn/index.tsx b/frontend/src/components/layouts/CheckIn/index.tsx index 232d823a1a..d51ad5a6ed 100644 --- a/frontend/src/components/layouts/CheckIn/index.tsx +++ b/frontend/src/components/layouts/CheckIn/index.tsx @@ -7,9 +7,9 @@ import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {t, Trans} from "@lingui/macro"; import {AxiosError} from "axios"; import classes from "./CheckIn.module.scss"; -import {ActionIcon, Button, Loader, Modal, Progress} from "@mantine/core"; +import {ActionIcon, Alert, Button, Loader, Modal, Progress, Stack} from "@mantine/core"; import {SearchBar} from "../../common/SearchBar"; -import {IconInfoCircle, IconQrcode, IconTicket} from "@tabler/icons-react"; +import {IconCreditCard, IconInfoCircle, IconQrcode, IconTicket, IconUserCheck} from "@tabler/icons-react"; import {QRScannerComponent} from "../../common/AttendeeCheckInTable/QrScanner.tsx"; import {useGetCheckInListAttendees} from "../../../queries/useGetCheckInListAttendeesPublic.ts"; import {useCreateCheckInPublic} from "../../../mutations/useCreateCheckInPublic.ts"; @@ -24,15 +24,20 @@ const CheckIn = () => { const {checkInListShortId} = useParams(); const CheckInListQuery = useGetCheckInListPublic(checkInListShortId); const checkInList = CheckInListQuery?.data?.data; + const event = checkInList?.event; + const eventSettings = event?.settings; const [searchQuery, setSearchQuery] = useState(''); const [searchQueryDebounced] = useDebouncedValue(searchQuery, 200); const [qrScannerOpen, setQrScannerOpen] = useState(false); + const [selectedAttendee, setSelectedAttendee] = useState(null); + const [checkInModalOpen, checkInModalHandlers] = useDisclosure(false); const [infoModalOpen, infoModalHandlers] = useDisclosure(false, { onOpen: () => { CheckInListQuery.refetch(); } } ); + const products = checkInList?.products; const queryFilters: QueryFilters = { pageNumber: 1, @@ -42,6 +47,7 @@ const CheckIn = () => { status: {operator: 'eq', value: 'ACTIVE'}, }, }; + const attendeesQuery = useGetCheckInListAttendees( checkInListShortId, queryFilters, @@ -50,6 +56,35 @@ const CheckIn = () => { const attendees = attendeesQuery?.data?.data; const checkInMutation = useCreateCheckInPublic(queryFilters); const deleteCheckInMutation = useDeleteCheckInPublic(queryFilters); + const allowOrdersAwaitingOfflinePaymentToCheckIn = eventSettings?.allow_orders_awaiting_offline_payment_to_check_in; + + const handleCheckInAction = (attendee: Attendee, action: 'check-in' | 'check-in-and-mark-order-as-paid') => { + checkInMutation.mutate({ + checkInListShortId: checkInListShortId, + attendeePublicId: attendee.public_id, + action: action, + }, { + onSuccess: ({errors}) => { + if (errors && errors[attendee.public_id]) { + showError(errors[attendee.public_id]); + return; + } + showSuccess({attendee.first_name} checked in successfully); + checkInModalHandlers.close(); + setSelectedAttendee(null); + }, + onError: (error) => { + if (!networkStatus.online) { + showError(t`You are offline`); + return; + } + + if (error instanceof AxiosError) { + showError(error?.response?.data.message || t`Unable to check in attendee`); + } + } + }); + }; const handleCheckInToggle = (attendee: Attendee) => { if (attendee.check_in) { @@ -68,70 +103,96 @@ const CheckIn = () => { showError(error?.response?.data.message || t`Unable to check out attendee`); } - }) + }); return; } - checkInMutation.mutate({ - checkInListShortId: checkInListShortId, - attendeePublicId: attendee.public_id, - }, { - onSuccess: ({errors}) => { - // Show error if there is an error for this specific attendee - // It's a bulk endpoint, so even if there's an error it returns a 200 - if (errors && errors[attendee.public_id]) { - showError(errors[attendee.public_id]); - return; - } + const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; - showSuccess({attendee.first_name} checked in successfully); - }, - onError: (error) => { - if (!networkStatus.online) { - showError(t`You are offline`); - return; - } + if (allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) { + setSelectedAttendee(attendee); + checkInModalHandlers.open(); + return; + } - if (error instanceof AxiosError) { - showError(error?.response?.data.message || t`Unable to check in attendee`); - } - } - } - ) - } + if (!allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) { + showError(t`You cannot check in attendees with unpaid orders.`); + return; + } - const handleQrCheckIn = (attendeePublicId: string, onRequestComplete: (didSucceed: boolean) => void, onFailure: () => void) => { - checkInMutation.mutate({ - checkInListShortId: checkInListShortId, - attendeePublicId: attendeePublicId, - }, { - onSuccess: ({errors}) => { - if (onRequestComplete) { - onRequestComplete(!(errors && errors[attendeePublicId])) - } - // Show error if there is an error for this specific attendee - // It's a bulk endpoint, so even if there's an error it returns a 200 - if (errors && errors[attendeePublicId]) { - showError(errors[attendeePublicId]); - return; - } + handleCheckInAction(attendee, 'check-in'); + }; - showSuccess(t`Checked in successfully`); - }, - onError: (error) => { - onFailure(); + const handleQrCheckIn = async (attendeePublicId: string) => { + // Find the attendee in the current list or fetch them + const attendee = attendees?.find(a => a.public_id === attendeePublicId); - if (!networkStatus.online) { - showError(t`You are offline`); - return; - } + if (!attendee) { + showError(t`Attendee not found`); + return; + } - if (error instanceof AxiosError) { - showError(error?.response?.data.message || t`Unable to check in attendee`); - } - } - }) - } + const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; + + if (allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) { + setSelectedAttendee(attendee); + checkInModalHandlers.open(); + return; + } + + if (!allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) { + showError(t`You cannot check in attendees with unpaid orders. This setting can be changed in the event settings.`); + return; + } + + handleCheckInAction(attendee, 'check-in'); + }; + + const CheckInOptionsModal = () => { + if (!selectedAttendee) return null; + + return ( + { + checkInModalHandlers.close(); + setSelectedAttendee(null); + }} + title={Check in {selectedAttendee.first_name} {selectedAttendee.last_name}} + size="md" + > + + + {t`This attendee has an unpaid order.`} + + + + + + + ); + }; const Attendees = () => { const Container = () => { @@ -154,33 +215,48 @@ const CheckIn = () => { return (
{attendees.map(attendee => { + const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT'; + return (
{attendee.first_name} {attendee.last_name}
+ {isAttendeeAwaitingPayment && ( +
+ {t`Awaiting payment`} +
+ )}
{attendee.public_id}
- {products.find(product => product.id === attendee.product_id)?.title} + {products.find(product => product.id === attendee.product_id)?.title}
- - {/*{attendee.check_in && (*/} - {/*
*/} - {/* checked in {relativeDate(attendee.check_in.checked_in_at)}*/} - {/*
*/} - {/*)}*/} + {attendee.check_in + ? t`Check Out` + : allowOrdersAwaitingOfflinePaymentToCheckIn ? t`Check In` : t`Cannot Check In`} + }
) @@ -247,7 +323,6 @@ const CheckIn = () => { />

- )} />) @@ -260,9 +335,7 @@ const CheckIn = () => { rightContent={( <> {!networkStatus.online && ( -
- {/**/} -
+
)} infoModalHandlers.open()} @@ -285,7 +358,7 @@ const CheckIn = () => { value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} onClear={() => setSearchQuery('')} - placeholder={t`Seach by name, order #, attendee # or email...`} + placeholder={t`Search by name, order #, attendee # or email...`} />
+ {qrScannerOpen && ( { setQrScannerOpen(false)} /> @@ -327,11 +401,11 @@ const CheckIn = () => { padding={'none'} > - - + +
@@ -369,4 +443,4 @@ const CheckIn = () => { ); } -export default CheckIn +export default CheckIn; diff --git a/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx b/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx index 162180efe0..d447245de0 100644 --- a/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx +++ b/frontend/src/components/layouts/Checkout/CheckoutFooter/index.tsx @@ -13,9 +13,10 @@ interface ContinueButtonProps { order: Order; event: Event; isOrderComplete?: boolean; + onClick?: () => void; } -export const CheckoutFooter = ({isLoading, buttonContent, event, order, isOrderComplete = false}: ContinueButtonProps) => { +export const CheckoutFooter = ({isLoading, buttonContent, event, order, onClick, isOrderComplete = false}: ContinueButtonProps) => { const [isSidebarOpen, setIsSidebarOpen] = useState(false); return ( @@ -30,8 +31,9 @@ export const CheckoutFooter = ({isLoading, buttonContent, event, order, isOrderC diff --git a/frontend/src/components/layouts/Checkout/index.tsx b/frontend/src/components/layouts/Checkout/index.tsx index 96ff974c07..364b3f69cb 100644 --- a/frontend/src/components/layouts/Checkout/index.tsx +++ b/frontend/src/components/layouts/Checkout/index.tsx @@ -5,20 +5,26 @@ import {t} from "@lingui/macro"; import {Countdown} from "../../common/Countdown"; import {CheckoutSidebar} from "./CheckoutSidebar"; import {ActionIcon, Button, Group, Modal, Tooltip} from "@mantine/core"; -import {IconArrowLeft, IconPrinter} from "@tabler/icons-react"; +import {IconArrowLeft, IconPrinter, IconReceipt} from "@tabler/icons-react"; import {eventHomepageUrl} from "../../../utilites/urlHelper.ts"; import {ShareComponent} from "../../common/ShareIcon"; import {AddToEventCalendarButton} from "../../common/AddEventToCalendarButton"; import {useMediaQuery} from "@mantine/hooks"; import {useState} from "react"; +import {Invoice} from "../../../types.ts"; +import {orderClientPublic} from "../../../api/order.client.ts"; +import {downloadBinary} from "../../../utilites/download.ts"; +import {showError} from "../../../utilites/notifications.tsx"; const Checkout = () => { const {eventId, orderShortId} = useParams(); const {data: order} = useGetOrderPublic(eventId, orderShortId, ['event']); const event = order?.event; + const eventSettings = event?.settings; const navigate = useNavigate(); const orderIsCompleted = order?.status === 'COMPLETED'; const orderIsReserved = order?.status === 'RESERVED'; + const orderIsAwaitingOfflinePayment = order?.status === 'AWAITING_OFFLINE_PAYMENT'; const isMobile = useMediaQuery('(max-width: 768px)'); const [isExpired, setIsExpired] = useState(false); const orderHasAttendees = order?.attendees && order.attendees.length > 0; @@ -31,6 +37,15 @@ const Checkout = () => { navigate(`/event/${event?.id}/${event?.slug}`); }; + const handleInvoiceDownload = async (invoice: Invoice) => { + try { + const blob = await orderClientPublic.downloadInvoice(eventId, orderShortId); + downloadBinary(blob, invoice.invoice_number + '.pdf'); + } catch (error) { + showError(t`Failed to download invoice. Please try again.`); + } + } + return ( <>
@@ -69,12 +84,12 @@ const Checkout = () => { )} - {orderIsCompleted && ( + {(orderIsCompleted || orderIsAwaitingOfflinePayment) && ( @@ -90,6 +105,18 @@ const Checkout = () => { )} + + {order.latest_invoice && ( + + handleInvoiceDownload(order.latest_invoice as Invoice)} + > + + + + )} )} diff --git a/frontend/src/components/modals/ManageAttendeeModal/index.tsx b/frontend/src/components/modals/ManageAttendeeModal/index.tsx index dc2f42aa35..f9aeccd1e1 100644 --- a/frontend/src/components/modals/ManageAttendeeModal/index.tsx +++ b/frontend/src/components/modals/ManageAttendeeModal/index.tsx @@ -8,13 +8,13 @@ import {useForm} from "@mantine/form"; import {Modal} from "../../common/Modal"; import {Accordion} from "../../common/Accordion"; import {Button} from "../../common/Button"; -import {Avatar, Badge, Box, Group, Stack, Tabs, Text, Textarea, TextInput} from "@mantine/core"; +import {Avatar, Box, Group, Stack, Tabs, Text, Textarea, TextInput} from "@mantine/core"; import {IconEdit, IconNotebook, IconQuestionMark, IconReceipt, IconTicket, IconUser} from "@tabler/icons-react"; import {LoadingMask} from "../../common/LoadingMask"; import {AttendeeDetails} from "../../common/AttendeeDetails"; import {OrderDetails} from "../../common/OrderDetails"; import {QuestionAndAnswerList} from "../../common/QuestionAndAnswerList"; -import {AttendeeProduct} from "../../common/AttendeeProduct"; +import {AttendeeTicket} from "../../common/AttendeeTicket"; import {getInitials} from "../../../utilites/helpers.ts"; import {t} from "@lingui/macro"; import classes from './ManageAttendeeModal.module.scss'; @@ -25,6 +25,7 @@ import {GenericModalProps, IdParam, ProductCategory, ProductType, QuestionAnswer import {InputGroup} from "../../common/InputGroup"; import {InputLabelWithHelp} from "../../common/InputLabelWithHelp"; import {EditAttendeeRequest} from "../../../api/attendee.client.ts"; +import {AttendeeStatusBadge} from "../../common/AttendeeStatusBadge"; interface ManageAttendeeModalProps extends GenericModalProps { onClose: () => void; @@ -170,7 +171,7 @@ export const ManageAttendeeModal = ({onClose, attendeeId}: ManageAttendeeModalPr icon: IconTicket, title: t`Attendee Ticket`, content: attendee.product ? ( - + ) : ( {t`No product associated with this attendee.`} @@ -206,9 +207,7 @@ export const ManageAttendeeModal = ({onClose, attendeeId}: ManageAttendeeModalPr
{fullName} - - {attendee.status} - +
diff --git a/frontend/src/components/modals/ViewOrderModal/ViewOrderModal.module.scss b/frontend/src/components/modals/ManageOrderModal/ManageOrderModal.module.scss similarity index 90% rename from frontend/src/components/modals/ViewOrderModal/ViewOrderModal.module.scss rename to frontend/src/components/modals/ManageOrderModal/ManageOrderModal.module.scss index eec8ad2bba..1753232905 100644 --- a/frontend/src/components/modals/ViewOrderModal/ViewOrderModal.module.scss +++ b/frontend/src/components/modals/ManageOrderModal/ManageOrderModal.module.scss @@ -8,7 +8,7 @@ display: flex; align-items: flex-start; justify-content: space-between; - margin-bottom: 1.5rem; + margin-bottom: 1rem; padding: 0 0.5rem; } diff --git a/frontend/src/components/modals/ManageOrderModal/index.tsx b/frontend/src/components/modals/ManageOrderModal/index.tsx new file mode 100644 index 0000000000..38668941b2 --- /dev/null +++ b/frontend/src/components/modals/ManageOrderModal/index.tsx @@ -0,0 +1,220 @@ +import {GenericModalProps, IdParam, Product, QuestionAnswer} from "../../../types.ts"; +import {useParams} from "react-router-dom"; +import {useGetEvent} from "../../../queries/useGetEvent.ts"; +import {useGetOrder} from "../../../queries/useGetOrder.ts"; +import {OrderSummary} from "../../common/OrderSummary"; +import {Modal} from "../../common/Modal"; +import {AttendeeList} from "../../common/AttendeeList"; +import {OrderDetails} from "../../common/OrderDetails"; +import {t} from "@lingui/macro"; +import {QuestionAndAnswerList} from "../../common/QuestionAndAnswerList"; +import {Box, Stack, Tabs, Text, Textarea, TextInput} from "@mantine/core"; +import {IconEdit, IconInfoCircle, IconNotebook, IconQuestionMark, IconReceipt, IconUsers} from "@tabler/icons-react"; +import {OrderStatusBadge} from "../../common/OrderStatusBadge"; +import {Accordion, AccordionItem} from "../../common/Accordion"; +import {useForm} from "@mantine/form"; +import {useEffect, useState} from "react"; +import {useEditOrder} from "../../../mutations/useEditOrder"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler"; +import {showSuccess} from "../../../utilites/notifications"; +import {Button} from "../../common/Button"; +import {InputGroup} from "../../common/InputGroup"; +import {InputLabelWithHelp} from "../../common/InputLabelWithHelp"; +import classes from './ManageOrderModal.module.scss'; +import {EditOrderPayload} from "../../../api/order.client.ts"; + +interface ManageOrderModalProps { + orderId: IdParam; +} + +export const ManageOrderModal = ({onClose, orderId}: GenericModalProps & ManageOrderModalProps) => { + const {eventId} = useParams(); + const {data: order} = useGetOrder(eventId, orderId); + const {data: event, data: {product_categories: productCategories} = {}} = useGetEvent(eventId); + const products = productCategories?.flatMap(category => category.products); + const orderHasQuestions = order?.question_answers && order.question_answers.length > 0; + const orderHasAttendees = order?.attendees && order.attendees.length > 0; + const [activeTab, setActiveTab] = useState("view"); + const errorHandler = useFormErrorResponseHandler(); + const mutation = useEditOrder(); + + const form = useForm({ + initialValues: { + first_name: "", + last_name: "", + email: "", + notes: "", + }, + }); + + useEffect(() => { + if (order) { + form.initialize({ + first_name: order.first_name, + last_name: order.last_name, + email: order.email, + notes: order.notes || "", + }); + } + }, [order]); + + if (!order || !event) { + return null; + } + + const handleSubmit = (values: EditOrderPayload) => { + mutation.mutate( + { + eventId, + orderId, + payload: values, + }, + { + onSuccess: () => { + showSuccess(t`Successfully updated order`); + setActiveTab("view"); + }, + onError: (error) => errorHandler(form, error), + } + ); + }; + + const accordionItems: AccordionItem[] = [ + { + value: 'details', + icon: IconInfoCircle, + title: t`Order Details`, + content: + }, + { + value: "notes", + icon: IconNotebook, + title: t`Order Notes`, + hidden: !order.notes, + content: ( + + + {order.notes} + + + ), + }, + { + value: 'summary', + icon: IconReceipt, + title: t`Order Summary`, + content: + }, + { + value: 'questions', + icon: IconQuestionMark, + title: t`Questions & Answers`, + count: orderHasQuestions ? order.question_answers.length : undefined, + content: orderHasQuestions ? ( + + ) : ( + + {t`No questions have been asked for this order.`} + + ) + }, + { + value: 'attendees', + icon: IconUsers, + title: t`Attendees`, + count: orderHasAttendees ? order.attendees.length : undefined, + content: orderHasAttendees ? ( + + ) : ( + + {t`No attendees have been added to this order.`} + + ) + } + ]; + + const editContent = ( +
+ + + + + + +