Skip to content

Commit 0ec576e

Browse files
committed
Improve webshop backend
1 parent ccd6009 commit 0ec576e

32 files changed

+887
-25
lines changed

.env.example

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,4 +239,30 @@ VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
239239
# VITE_LOCAL_DEV=true
240240
# VITE_HTTP_PROXY_TARGET=http://localhost:8000
241241

242-
# DISABLE_IMPORT_FROM_SERVER=false
242+
# DISABLE_IMPORT_FROM_SERVER=false
243+
244+
###################################################################
245+
# Payment integration (requires SE) #
246+
###################################################################
247+
248+
# Enable test mode (Sandbox mode) for payment gateways.
249+
# In test mode, no real money transactions are done.
250+
# OMNIPAY_TEST_MODE=
251+
252+
# Configuration values for Mollie integration
253+
# MOLLIE_API_KEY=
254+
# MOLLIE_PROFILE_ID=
255+
256+
# Configuration values for Stripe integration (NOT WORKING YET, MAYBE LATER)
257+
# STRIPE_API_KEY=
258+
# STRIPE_PUBLISHABLE_KEY=
259+
260+
# https://github.com/thephpleague/omnipay-paypal/blob/master/src/RestGateway.php
261+
# PAYPAL_CLIENT_ID=
262+
# PAYPAL_SECRET=
263+
264+
# https://github.com/thephpleague/omnipay-paypal/blob/master/src/ExpressInContextGateway.php
265+
# https://github.com/thephpleague/omnipay-paypal/blob/master/src/ProGateway.php
266+
# PAYPAL_API_USERNAME=
267+
# PAYPAL_API_PASSWORD=
268+
# PAYPAL_API_SIGNATURE=

app/Actions/Diagnostics/Pipes/Checks/WebshopCheck.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ public function handle(array &$data, \Closure $next): array
3838
return $next($data);
3939
}
4040

41+
if (config('omnipay.test_mode', false) === true) {
42+
$data[] = DiagnosticData::warn(
43+
'Webshop is running in test mode.',
44+
self::class,
45+
['This means that payments won\'t be executed.', 'Users may use it to get free content.']
46+
);
47+
}
48+
4149
if (config('app.env', 'production') !== 'production') {
4250
$data[] = DiagnosticData::warn(
4351
'Webshop is enabled but the application is not running in production mode.',

app/Actions/Shop/CheckoutService.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Models\Order;
1717
use App\Services\MoneyService;
1818
use Illuminate\Support\Facades\Log;
19+
use Illuminate\Support\Facades\Session;
1920
use Omnipay\Common\Exception\InvalidCreditCardException;
2021
use Omnipay\Common\Message\RedirectResponseInterface;
2122
use Omnipay\Common\Message\ResponseInterface;
@@ -66,6 +67,9 @@ public function processPayment(Order $order, string $return_url, string $cancel_
6667
// Prepare the purchase request parameters
6768
$params = $this->preparePurchaseParameters($order, $return_url, $cancel_url, $additional_data);
6869

70+
// Save the parameters.
71+
Session::put('processing', $params);
72+
6973
try {
7074
// Update order status to processing
7175
$order->status = PaymentStatusType::PROCESSING;
@@ -151,14 +155,14 @@ public function completePayment(Order $order, ResponseInterface $response): Orde
151155
/**
152156
* Handle the return from the payment gateway.
153157
*
154-
* @param Order $order The order being processed
155-
* @param array $request_data The request data from the payment gateway
156-
* @param OmnipayProviderType $provider The payment provider used
158+
* @param Order $order The order being processed
159+
* @param OmnipayProviderType $provider The payment provider used
157160
*
158161
* @return Order|null The updated order if found, null otherwise
159162
*/
160-
public function handlePaymentReturn(Order $order, array $request_data, OmnipayProviderType $provider): ?Order
163+
public function handlePaymentReturn(Order $order, OmnipayProviderType $provider): ?Order
161164
{
165+
$request_data = Session::get('processing', []);
162166
$gateway = $this->omnipay_factory->create_gateway($provider);
163167

164168
try {

app/Actions/Shop/OrderService.php

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,13 @@
1717
use App\Models\OrderItem;
1818
use App\Models\Photo;
1919
use App\Models\User;
20-
use App\Services\MoneyService;
20+
use Illuminate\Database\Eloquent\Builder;
2121
use Illuminate\Support\Facades\Auth;
2222
use Illuminate\Support\Str;
2323

2424
class OrderService
2525
{
2626
public function __construct(
27-
private MoneyService $money_service,
2827
private PurchasableService $purchasable_service,
2928
) {
3029
}
@@ -123,4 +122,93 @@ public function getAll(): array
123122
})
124123
->orderBy('id', 'desc')->get()->all();
125124
}
125+
126+
/**
127+
* Clear old orders that are older than 2 weeks, have no items, and have no user_id.
128+
*
129+
* @return int Number of orders deleted
130+
*/
131+
public function clearOldOrders(): int
132+
{
133+
// Delete all the order items first to avoid foreign key constraint issues
134+
OrderItem::whereIn('order_id', $this->getQueryOldOrders()->select('id'))->delete();
135+
136+
return $this->getQueryOldOrders()->delete();
137+
}
138+
139+
/**
140+
* Count the number of old orders.
141+
*
142+
* @return int
143+
*/
144+
public function countOldOrders(): int
145+
{
146+
return $this->getQueryOldOrders()->count();
147+
}
148+
149+
/**
150+
* Return the query builder for old orders.
151+
*
152+
* An old order is defined as being older than $weeks weeks,
153+
* - having no user_id,
154+
* - having no items
155+
* - or having items but status is still PENDING
156+
*
157+
* @param int $weeks
158+
*
159+
* @return Builder
160+
*/
161+
protected function getQueryOldOrders(int $weeks = 2): Builder
162+
{
163+
$threshold_date = now()->subWeeks($weeks);
164+
165+
return Order::where('created_at', '<', $threshold_date)
166+
->whereNull('user_id')
167+
->where(function (Builder $query): void {
168+
$query->where('status', PaymentStatusType::PENDING)
169+
->orWhereDoesntHave('items');
170+
});
171+
}
172+
173+
/**
174+
* Mark an offline order as paid (completed).
175+
*
176+
* @param Order $order The order to mark as paid
177+
*
178+
* @return Order The updated order
179+
*
180+
* @throws \InvalidArgumentException If the order is not in offline status
181+
*/
182+
public function markAsPaid(Order $order): Order
183+
{
184+
if ($order->status !== PaymentStatusType::OFFLINE) {
185+
throw new \InvalidArgumentException('Order must be in offline status to be marked as paid');
186+
}
187+
188+
$order->status = PaymentStatusType::COMPLETED;
189+
$order->save();
190+
191+
return $order;
192+
}
193+
194+
/**
195+
* Mark a completed order as delivered (closed).
196+
*
197+
* @param Order $order The order to mark as delivered
198+
*
199+
* @return Order The updated order
200+
*
201+
* @throws \InvalidArgumentException If the order is not in completed status
202+
*/
203+
public function markAsDelivered(Order $order): Order
204+
{
205+
if ($order->status !== PaymentStatusType::COMPLETED) {
206+
throw new \InvalidArgumentException('Order must be in completed status to be marked as delivered');
207+
}
208+
209+
$order->status = PaymentStatusType::CLOSED;
210+
$order->save();
211+
212+
return $order;
213+
}
126214
}

app/Enum/OmnipayProviderType.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ public function requiredKeys(): array
5050
{
5151
return match ($this) {
5252
OmnipayProviderType::DUMMY => ['apiKey'],
53-
OmnipayProviderType::MOLLIE => ['apiKey'],
54-
OmnipayProviderType::STRIPE => ['apiKey'],
53+
OmnipayProviderType::MOLLIE => ['apiKey', 'profileId'],
54+
OmnipayProviderType::STRIPE => ['apiKey', 'publishable_key'],
5555
OmnipayProviderType::PAYPAL_REST => ['clientId', 'secret'],
5656
OmnipayProviderType::PAYPAL_EXPRESS,
5757
OmnipayProviderType::PAYPAL_PRO,

app/Enum/PaymentStatusType.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,16 @@ enum PaymentStatusType: string
3030
// Intermediate state during payment processing
3131
case PROCESSING = 'processing';
3232

33-
// Final state
33+
// When processing is done ofline.
34+
// In such case we do not go through the full flow.
35+
case OFFLINE = 'offline';
36+
37+
// Final payment state
3438
case COMPLETED = 'completed';
3539

40+
// The order is closed for any further action: paid and delivered.
41+
case CLOSED = 'closed';
42+
3643
public function canCheckout()
3744
{
3845
return match ($this) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Http\Controllers\Admin\Maintenance;
10+
11+
use App\Actions\Shop\OrderService;
12+
use App\Http\Requests\Maintenance\MaintenanceRequest;
13+
use Illuminate\Routing\Controller;
14+
15+
/**
16+
* We count the number of old orders and can flush them.
17+
*/
18+
class FlushOldOrders extends Controller
19+
{
20+
public function __construct(protected OrderService $order_service)
21+
{
22+
}
23+
24+
/**
25+
* Delete old orders.
26+
*/
27+
public function do(MaintenanceRequest $request): void
28+
{
29+
$this->order_service->clearOldOrders();
30+
}
31+
32+
/**
33+
* Count the number of old orders.
34+
*
35+
* @return int
36+
*/
37+
public function check(MaintenanceRequest $request): int
38+
{
39+
return $this->order_service->countOldOrders();
40+
}
41+
}

app/Http/Controllers/Admin/SettingsController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public function getAll(GetAllConfigsRequest $request, DockerVersionInfo $docker_
4747
->when(config('features.hide-lychee-SE', false) === true, fn ($q) => $q->where('cat', '!=', 'lychee SE'))
4848
->when($docker_info->isDocker(), fn ($q) => $q->where('not_on_docker', '!=', true))
4949
->when(!$request->is_se() && !Configs::getValueAsBool('enable_se_preview'), fn ($q) => $q->where('level', '=', 0))
50-
->when(config('features.enable-webshop') === false, fn ($q) => $q->where('key', 'NOT LIKE', 'webshop_%')),
50+
->when(config('features.webshop') === false, fn ($q) => $q->where('key', 'NOT LIKE', 'webshop_%')),
5151
])->orderBy('order', 'asc')->get();
5252

5353
return ConfigCategoryResource::collect($editable_configs)->filter(fn ($cat) => $cat->configs->isNotEmpty())->values();

app/Http/Controllers/Shop/CheckoutController.php

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
use App\Enum\PaymentStatusType;
1313
use App\Http\Requests\Checkout\CreateSessionRequest;
1414
use App\Http\Requests\Checkout\FinalizeRequest;
15+
use App\Http\Requests\Checkout\OfflineRequest;
1516
use App\Http\Requests\Checkout\ProcessRequest;
1617
use App\Http\Resources\Shop\CheckoutOptionResource;
1718
use App\Http\Resources\Shop\CheckoutResource;
1819
use App\Http\Resources\Shop\OrderResource;
1920
use Illuminate\Routing\Controller;
21+
use Illuminate\Support\Facades\Log;
2022
use Illuminate\Support\Facades\URL;
2123

2224
class CheckoutController extends Controller
@@ -110,7 +112,9 @@ public function process(ProcessRequest $request): CheckoutResource
110112
public function finalize(FinalizeRequest $request, string $provider, string $transaction_id): CheckoutResource
111113
{
112114
/** @disregard P1013 */
113-
$order = $this->checkout_service->handlePaymentReturn($request->basket(), $request->all(), $request->provider_type());
115+
Log::warning("Finalize payment for provider {$provider} and transaction ID {$transaction_id}", $request->all());
116+
/** @disregard P1013 */
117+
$order = $this->checkout_service->handlePaymentReturn($request->basket(), $request->provider_type());
114118

115119
if ($order->status !== PaymentStatusType::COMPLETED) {
116120
return new CheckoutResource(
@@ -145,4 +149,39 @@ public function cancel(FinalizeRequest $request): CheckoutResource
145149
order: OrderResource::fromModel($order),
146150
);
147151
}
152+
153+
/**
154+
* Handle offline order completion.
155+
*
156+
* @param OfflineRequest $request
157+
*
158+
* @return CheckoutResource
159+
*/
160+
public function offline(OfflineRequest $request): CheckoutResource
161+
{
162+
$order = $request->basket();
163+
164+
// Add email if provided
165+
if ($request->email !== null) {
166+
$order->email = $request->email;
167+
}
168+
169+
if ($order->email === null || $order->email === '') {
170+
return new CheckoutResource(
171+
is_success: false,
172+
message: 'Email is required for offline orders.',
173+
order: OrderResource::fromModel($order),
174+
);
175+
}
176+
177+
// Mark the order as completed (offline)
178+
$order->status = PaymentStatusType::OFFLINE;
179+
$order->save();
180+
181+
return new CheckoutResource(
182+
is_success: true,
183+
message: 'Order marked as completed (offline)',
184+
order: OrderResource::fromModel($order),
185+
);
186+
}
148187
}

app/Http/Controllers/Shop/OrderController.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
namespace App\Http\Controllers\Shop;
1010

1111
use App\Actions\Shop\OrderService;
12+
use App\Http\Requests\Order\ClearOldOrdersRequest;
1213
use App\Http\Requests\Order\GetOrderRequest;
1314
use App\Http\Requests\Order\ListOrderRequest;
15+
use App\Http\Requests\Order\MarkAsDeliveredOrderRequest;
16+
use App\Http\Requests\Order\MarkAsPaidOrderRequest;
1417
use App\Http\Resources\Shop\OrderResource;
1518
use Illuminate\Routing\Controller;
1619

@@ -44,4 +47,38 @@ public function get(GetOrderRequest $request): OrderResource
4447
{
4548
return OrderResource::fromModel($request->order);
4649
}
50+
51+
/**
52+
* Clear old orders that are still pending.
53+
*
54+
* @return void
55+
*/
56+
public function clearOldOrders(ClearOldOrdersRequest $request): void
57+
{
58+
$this->order_service->clearOldOrders();
59+
}
60+
61+
/**
62+
* Mark an order as paid.
63+
*
64+
* @param MarkAsPaidOrderRequest $request
65+
*
66+
* @return void
67+
*/
68+
public function markAsPaid(MarkAsPaidOrderRequest $request): void
69+
{
70+
$this->order_service->markAsPaid($request->order);
71+
}
72+
73+
/**
74+
* Mark an order as delivered.
75+
*
76+
* @param MarkAsDeliveredOrderRequest $request
77+
*
78+
* @return void
79+
*/
80+
public function markAsDelivered(MarkAsDeliveredOrderRequest $request): void
81+
{
82+
$this->order_service->markAsDelivered($request->order);
83+
}
4784
}

0 commit comments

Comments
 (0)