Skip to content

Commit 7f197b1

Browse files
authored
Fix fee calculations for free tickets (#500) (#501)
2 parents 34a16b2 + 855f7a7 commit 7f197b1

File tree

6 files changed

+161
-10
lines changed

6 files changed

+161
-10
lines changed

backend/app/DomainObjects/OrderDomainObject.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use HiEvents\Helper\AddressHelper;
1212
use Illuminate\Support\Carbon;
1313
use Illuminate\Support\Collection;
14+
use RuntimeException;
1415

1516
class OrderDomainObject extends Generated\OrderDomainObjectAbstract implements IsSortable, IsFilterable
1617
{
@@ -229,6 +230,15 @@ public function setQuestionAndAnswerViews(?Collection $questionAndAnswerViews):
229230
return $this;
230231
}
231232

233+
public function getTotalQuantity(): int
234+
{
235+
if ($this->getOrderItems() === null) {
236+
throw new RuntimeException('Cannot calculate total quantity, order items are null');
237+
}
238+
239+
return $this->getOrderItems()->sum(fn(OrderItemDomainObject $item) => $item->getQuantity());
240+
}
241+
232242
public function getQuestionAndAnswerViews(): ?Collection
233243
{
234244
return $this->questionAndAnswerViews;

backend/app/Services/Domain/Order/MarkOrderAsPaidService.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ public function markOrderAsPaid(
8181

8282
$this->updateOrderInvoice($orderId);
8383

84-
$updatedOrder = $this->orderRepository->findById($orderId);
84+
$updatedOrder = $this->orderRepository
85+
->loadRelation(OrderItemDomainObject::class)
86+
->findById($orderId);
8587

8688
$this->updateAttendeeStatuses($updatedOrder);
8789

@@ -168,9 +170,8 @@ private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): vo
168170
$this->orderApplicationFeeService->createOrderApplicationFee(
169171
orderId: $updatedOrder->getId(),
170172
applicationFeeAmountMinorUnit: $this->orderApplicationFeeCalculationService->calculateApplicationFee(
171-
$config,
172-
$updatedOrder->getTotalGross(),
173-
$event->getCurrency(),
173+
accountConfiguration: $config,
174+
order: $updatedOrder,
174175
)->toMinorUnit(),
175176
orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT,
176177
paymentMethod: PaymentProviders::OFFLINE,

backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Brick\Money\Currency;
66
use HiEvents\DomainObjects\AccountConfigurationDomainObject;
7+
use HiEvents\DomainObjects\OrderDomainObject;
78
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
89
use HiEvents\Values\MoneyValue;
910
use Illuminate\Config\Repository;
@@ -21,10 +22,12 @@ public function __construct(
2122

2223
public function calculateApplicationFee(
2324
AccountConfigurationDomainObject $accountConfiguration,
24-
float $orderTotal,
25-
string $currency
25+
OrderDomainObject $order,
2626
): MoneyValue
2727
{
28+
$currency = $order->getCurrency();
29+
$quantityPurchased = $this->getChargeableQuantityPurchased($order);
30+
2831
if (!$this->config->get('app.saas_mode_enabled')) {
2932
return MoneyValue::zero($currency);
3033
}
@@ -33,7 +36,7 @@ public function calculateApplicationFee(
3336
$percentageFee = $accountConfiguration->getPercentageApplicationFee();
3437

3538
return MoneyValue::fromFloat(
36-
amount: $fixedFee->toFloat() + ($orderTotal * $percentageFee / 100),
39+
amount: ($fixedFee->toFloat() * $quantityPurchased) + ($order->getTotalGross() * $percentageFee / 100),
3740
currency: $currency
3841
);
3942
}
@@ -53,4 +56,16 @@ private function getConvertedFixedFee(
5356
amount: $accountConfiguration->getFixedApplicationFee()
5457
);
5558
}
59+
60+
private function getChargeableQuantityPurchased(OrderDomainObject $order): int
61+
{
62+
$quantityPurchased = 0;
63+
foreach ($order->getOrderItems() as $item) {
64+
if ($item->getPrice() > 0) {
65+
$quantityPurchased += $item->getQuantity();
66+
}
67+
}
68+
69+
return $quantityPurchased;
70+
}
5671
}

backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ public function createPaymentIntent(CreatePaymentIntentRequestDTO $paymentIntent
6464

6565
$applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee(
6666
accountConfiguration: $paymentIntentDTO->account->getConfiguration(),
67-
orderTotal: $paymentIntentDTO->amount->toFloat(),
68-
currency: $paymentIntentDTO->currencyCode,
67+
order: $paymentIntentDTO->order,
6968
)->toMinorUnit();
7069

7170
$paymentIntent = $this->stripeClient->paymentIntents->create([
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace Tests\Unit\Services\Domain\Order;
4+
5+
use HiEvents\DomainObjects\AccountConfigurationDomainObject;
6+
use HiEvents\DomainObjects\OrderDomainObject;
7+
use HiEvents\DomainObjects\OrderItemDomainObject;
8+
use HiEvents\Services\Domain\Order\OrderApplicationFeeCalculationService;
9+
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
10+
use HiEvents\Values\MoneyValue;
11+
use Illuminate\Config\Repository;
12+
use PHPUnit\Framework\TestCase;
13+
14+
class OrderApplicationFeeCalculationServiceTest extends TestCase
15+
{
16+
private Repository $config;
17+
private CurrencyConversionClientInterface $currencyConversionClient;
18+
private OrderApplicationFeeCalculationService $service;
19+
20+
protected function setUp(): void
21+
{
22+
$this->config = $this->createMock(Repository::class);
23+
$this->currencyConversionClient = $this->createMock(CurrencyConversionClientInterface::class);
24+
$this->service = new OrderApplicationFeeCalculationService($this->config, $this->currencyConversionClient);
25+
}
26+
27+
private function createOrderWithItems(array $items, string $currency = 'USD'): OrderDomainObject
28+
{
29+
$order = (new OrderDomainObject())
30+
->setCurrency($currency)
31+
->setOrderItems(collect($items));
32+
33+
// Calculate gross manually for test accuracy
34+
$total = collect($items)->reduce(fn($carry, $item) => $carry + ($item->getPrice() * $item->getQuantity()), 0);
35+
$order->setTotalGross($total);
36+
37+
return $order;
38+
}
39+
40+
private function createItem(float $price, int $quantity): OrderItemDomainObject
41+
{
42+
$item = $this->createMock(OrderItemDomainObject::class);
43+
$item->method('getPrice')->willReturn($price);
44+
$item->method('getQuantity')->willReturn($quantity);
45+
return $item;
46+
}
47+
48+
private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0): AccountConfigurationDomainObject
49+
{
50+
$config = $this->getMockBuilder(AccountConfigurationDomainObject::class)
51+
->disableOriginalConstructor()
52+
->onlyMethods(['getFixedApplicationFee', 'getPercentageApplicationFee'])
53+
->getMock();
54+
55+
$config->method('getFixedApplicationFee')->willReturn($fixedFee);
56+
$config->method('getPercentageApplicationFee')->willReturn($percentageFee);
57+
58+
return $config;
59+
}
60+
61+
public function testNoFeeWhenSaasModeDisabled(): void
62+
{
63+
$this->config->method('get')->with('app.saas_mode_enabled')->willReturn(false);
64+
65+
$order = $this->createOrderWithItems([$this->createItem(10, 2)]);
66+
$account = $this->createAccountConfig(1, 1);
67+
68+
$fee = $this->service->calculateApplicationFee($account, $order);
69+
70+
$this->assertEquals(0.0, $fee->toFloat());
71+
}
72+
73+
public function testNoFeeForFreeOrder(): void
74+
{
75+
$this->config->method('get')->willReturn(true);
76+
77+
$order = $this->createOrderWithItems([$this->createItem(0, 3)]);
78+
$account = $this->createAccountConfig(1, 1);
79+
80+
$fee = $this->service->calculateApplicationFee($account, $order);
81+
82+
$this->assertEquals(0.0, $fee->toFloat());
83+
}
84+
85+
public function testFixedAndPercentageFeeSameCurrency(): void
86+
{
87+
$this->config->method('get')->willReturn(true);
88+
89+
$order = $this->createOrderWithItems([
90+
$this->createItem(10, 1), // chargeable
91+
$this->createItem(0, 5), // free
92+
$this->createItem(20, 2), // chargeable
93+
]);
94+
95+
$account = $this->createAccountConfig(2.00, 1); // 2 USD fixed, 1% percentage
96+
97+
// 3 chargeable items × $2 fixed = $6
98+
// $10 + $40 = $50 gross → 1% of $50 = $0.50
99+
// Total = $6.50
100+
$fee = $this->service->calculateApplicationFee($account, $order);
101+
102+
$this->assertEquals(6.50, $fee->toFloat());
103+
}
104+
105+
public function testCurrencyConversionForFixedFee(): void
106+
{
107+
$this->config->method('get')->willReturn(true);
108+
109+
$order = $this->createOrderWithItems([
110+
$this->createItem(15, 1), // chargeable
111+
$this->createItem(0, 3), // free
112+
], 'EUR');
113+
114+
$account = $this->createAccountConfig(1.50, 20);
115+
116+
$this->currencyConversionClient->method('convert')
117+
->willReturn(MoneyValue::fromFloat(2.00, 'EUR')); // fixed fee per chargeable = €2.00
118+
119+
// 1 chargeable × €2 fixed = €2
120+
// 15 × 20% = €3 percentage
121+
// Total = €5
122+
$fee = $this->service->calculateApplicationFee($account, $order);
123+
124+
$this->assertEquals(5.00, $fee->toFloat());
125+
}
126+
}

docker/development/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ APP_DEBUG=true
55

66
API_URL_CLIENT=https://localhost:8443/api
77
API_URL_SERVER=http://backend:8080
8-
STRIPE_PUBLIC_KEY=pk_test_XX
8+
STRIPE_PUBLIC_KEY=pk_test_xxx
99
FRONTEND_URL=https://localhost:8443
1010

1111
DB_CONNECTION=pgsql

0 commit comments

Comments
 (0)