#18 - membership

This commit was merged in pull request #23.
This commit is contained in:
Martin Slachta
2026-06-17 11:15:09 +02:00
parent df5f9b1df4
commit c754e18a82
25 changed files with 885 additions and 35 deletions
@@ -1,6 +1,6 @@
<?php
/** Computes a form's total price as the sum of its elements' prices. */
/** Computes a form's total price as the sum of its elements' prices, reduced by membership discount. */
class RsvFormPriceCalculator {
public function calculate(RsvFormDefinition $definition, RsvFormData $data): float {
global $rsv_form_price_registry;
@@ -16,6 +16,7 @@ class RsvFormPriceCalculator {
$total += (float) $calculator($element, $data->getValue($element->getName()));
}
return $total;
$pct = (new RsvMembershipService())->discount_for($definition, $data);
return $total * (1.0 - max(0.0, min(100.0, $pct)) / 100.0);
}
}
@@ -4,8 +4,25 @@
final class RsvFormCalculatedValues {
/** @return array<string, mixed> */
public function for(RsvFormDefinition $definition, RsvFormData $data): array {
$calculator = new RsvFormPriceCalculator();
global $rsv_form_price_registry;
$price_before_discount = 0.0;
foreach ($definition->getElements() as $element) {
$element_calculator = $rsv_form_price_registry->get($element->getType());
if ($element_calculator === null) {
continue;
}
$price_before_discount += (float) $element_calculator($element, $data->getValue($element->getName()));
}
$discount_pct = (new RsvMembershipService())->discount_for($definition, $data);
$final_price = $calculator->calculate($definition, $data);
return [
'price' => (new RsvFormPriceCalculator())->calculate($definition, $data),
'price' => $final_price,
'price_before_discount' => $price_before_discount,
'discount_percent' => $discount_pct,
];
}
@@ -14,6 +31,6 @@ final class RsvFormCalculatedValues {
* @return list<string>
*/
public static function names(): array {
return ['price'];
return ['price', 'price_before_discount', 'discount_percent'];
}
}
+13 -1
View File
@@ -7,10 +7,12 @@ class RsvFormDefinition {
public string $email_key = "";
public array $membership = [];
public string $success_message = "";
/**
* @param array<int,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
* @param array<string,mixed> $definition Full definition array including 'elements', 'email_key' and 'success_message'.
*/
public function __construct(string $id, array $definition) {
$this->_elements = [];
@@ -23,6 +25,7 @@ class RsvFormDefinition {
$this->_id = $id;
$this->email_key = $definition['email_key'] ?? '';
$this->membership = $definition['membership'] ?? [];
$this->success_message = $definition['success_message'] ?? '';
}
@@ -34,6 +37,15 @@ class RsvFormDefinition {
return $this->email_key;
}
/** @return array<int,array{program_id:int,discount:float,field:string}> */
public function getMembershipBindings(): array {
return $this->membership['bindings'] ?? [];
}
public function getMembershipCombine(): string {
return $this->membership['combine'] ?? 'max';
}
/** Template shown to the visitor after a successful submission. */
public function getSuccessMessage(): string {
return $this->success_message;
@@ -34,6 +34,9 @@ final class RsvFormDefinitionValidator {
$errors[] = 'Form must contain a submit button.';
}
// Validate membership bindings if present.
$errors = array_merge($errors, $this->validate_membership_bindings($definition));
return $errors;
}
@@ -109,4 +112,41 @@ final class RsvFormDefinitionValidator {
global $rsv_template_registry;
return new RsvTemplateEngine(registry: $rsv_template_registry);
}
/**
* @param array<string,mixed> $definition
* @return list<string>
*/
private function validate_membership_bindings(array $definition): array {
$errors = [];
$membership = $definition['membership'] ?? [];
if (!is_array($membership)) {
return $errors;
}
$bindings = $membership['bindings'] ?? [];
if (!is_array($bindings)) {
return $errors;
}
foreach ($bindings as $idx => $binding) {
if (!is_array($binding)) {
continue;
}
$program_id = intval($binding['program_id'] ?? 0);
$discount = floatval($binding['discount'] ?? 0.0);
if ($program_id <= 0) {
$errors[] = "Membership binding {$idx}: program_id must be a positive integer.";
}
if ($discount < 0.0 || $discount > 100.0) {
$errors[] = "Membership binding {$idx}: discount must be between 0 and 100.";
}
}
return $errors;
}
}
@@ -0,0 +1,9 @@
# Membership
Forms can have a price calculated from it's definition and submitted data. For a discount, the administrator can create a membership program. In the system, that means a collection of strings. In the form definition, the administrator defines, which membership program applies what discount and how to extract the key into the membership program from the from submission.
## Matching the keys
The membership is a collection of keys. But some types of them are hard to match exactly. For example it might be important to ignore case or be culture invariant. For this reason, second column in the membership table is used, which is for the normalized key, that must be matched exactly against another normalized key.
For example, for full names, the process of normalization might remove diacritics or convert to lowercase. The same process must be applied when looking for the key.
@@ -0,0 +1,50 @@
<?php
class RsvMembershipService {
/**
* Total membership discount for a submission.
*
* Each binding names a form field whose submitted value must match a key
* in the bound program. Matching bindings' discounts are combined per the
* definition's combine mode: the best single discount, or all summed and
* capped at 100%.
*/
public function discount_for(RsvFormDefinition $def, RsvFormData $data): float {
$repo = new RsvMembershipProgramRepository();
$matched_discounts = [];
foreach ($def->getMembershipBindings() as $binding) {
$program_id = intval($binding['program_id'] ?? 0);
$discount = floatval($binding['discount'] ?? 0.0);
$field = strval($binding['field'] ?? '');
if ($program_id <= 0 || $field === '') {
continue;
}
$raw = $data->getValue($field, '');
$value = is_scalar($raw) ? trim((string) $raw) : '';
if ($value === '') {
continue;
}
if ($repo->key_exists($program_id, $value)) {
$matched_discounts[] = $discount;
}
}
if (empty($matched_discounts)) {
return 0.0;
}
if ($def->getMembershipCombine() === 'sum') {
return min(100.0, array_sum($matched_discounts));
}
return max($matched_discounts);
}
}
+15 -2
View File
@@ -53,9 +53,10 @@ class RsvReservationService {
try {
$reservation_id = $this->repo->insert($reservation->to_array());
$reservation->id = $reservation_id;
foreach ($reservation->timetable_reservations as $timetable_reservation) {
$timetable_reservation_service->create($reservation_id, $timetable_reservation);
$timetable_reservation->id = $timetable_reservation_service->create($reservation_id, $timetable_reservation);
}
Db::commit();
@@ -67,7 +68,19 @@ class RsvReservationService {
// Only now that the rows are durably committed do we let listeners
// (maintainer emails, calendar sync) observe the new reservation.
$timetable_reservation_service->flush_deferred_events();
foreach($reservation->timetable_reservations as $timetable_reservation) {
if($timetable_reservation->is_confirmed === null) {
$maintainer_email = (new RsvTimetableRepository())->get_maintainer_email($timetable_reservation->timetable_id);
RsvEventDispatcher::dispatch(new RsvTimetableReservationPendingEvent(
$reservation_id,
$timetable_reservation,
$timetable_reservation_service->get_confirmation_code($timetable_reservation->id),
$maintainer_email
));
}
RsvEventDispatcher::dispatch(new RsvTimetableReservationCreatedEvent($timetable_reservation, $reservation));
}
$this->confirmation_state_changed($reservation_id);
@@ -115,13 +115,9 @@ class RsvTimetableReservationService {
return $this->repo->has_pending_confirmation($reservation_id);
}
private function get_confirmation_code(int $reservation_id): string {
public function get_confirmation_code(int $reservation_id): ?string {
$code = $this->repo->get_confirmation_code($reservation_id);
if ($code === null) {
throw new InvalidArgumentException('No pending confirmation for this reservation.');
}
return $code;
}
@@ -157,20 +153,13 @@ class RsvTimetableReservationService {
if ($maintainer_email) {
$code = $this->create_confirmation($reservation_id, $id);
$this->deferred_events[] = new RsvTimetableReservationPendingEvent(
$reservation_id,
$reservation,
$code,
$maintainer_email
);
}
} else {
$reservation->is_confirmed = 1;
$reservation_service = new RsvReservationService();
$reservation_service->confirmation_state_changed($reservation_id);
}
$this->deferred_events[] = new RsvTimetableReservationCreatedEvent($reservation);
return $id;
}