diff --git a/app/Events/Vouchers/VoucherSendToEmailBySponsorEvent.php b/app/Events/Vouchers/VoucherSendToEmailBySponsorEvent.php new file mode 100644 index 000000000..d4d9e9433 --- /dev/null +++ b/app/Events/Vouchers/VoucherSendToEmailBySponsorEvent.php @@ -0,0 +1,31 @@ +email = $email; + } + + /** + * @return string|null + */ + public function getEmail(): ?string + { + return $this->email; + } +} diff --git a/app/Events/Vouchers/VoucherSendToEmailEvent.php b/app/Events/Vouchers/VoucherSendToEmailEvent.php index f4e96484f..4acd0be38 100644 --- a/app/Events/Vouchers/VoucherSendToEmailEvent.php +++ b/app/Events/Vouchers/VoucherSendToEmailEvent.php @@ -2,30 +2,6 @@ namespace App\Events\Vouchers; -use App\Models\Voucher; - class VoucherSendToEmailEvent extends BaseVoucherEvent { - protected string $email; - - /** - * Create a new event instance. - * - * @param Voucher $voucher - * @param string $email - */ - public function __construct(Voucher $voucher, string $email) - { - parent::__construct($voucher); - - $this->email = $email; - } - - /** - * @return string - */ - public function getEmail(): string - { - return $this->email; - } } diff --git a/app/Helpers/Number.php b/app/Helpers/Number.php new file mode 100644 index 000000000..2411008db --- /dev/null +++ b/app/Helpers/Number.php @@ -0,0 +1,15 @@ +authorize('show', $organization); $this->authorize('sendByEmailSponsor', [$voucher, $organization]); - $voucher->sendToEmail($request->post('email')); + Event::dispatch(new VoucherSendToEmailBySponsorEvent( + $voucher, + $voucher->granted ? null : $request->post('email') + )); return SponsorVoucherResource::create($voucher); } diff --git a/app/Http/Controllers/Api/Platform/PayoutsController.php b/app/Http/Controllers/Api/Platform/PayoutsController.php index 8a5b5ed52..e42c2a947 100644 --- a/app/Http/Controllers/Api/Platform/PayoutsController.php +++ b/app/Http/Controllers/Api/Platform/PayoutsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Platform; +use App\Helpers\Number; use App\Http\Controllers\Controller; use App\Http\Requests\Api\Platform\Payouts\IndexPayoutsRequest; use App\Http\Requests\Api\Platform\Payouts\StorePayoutRequest; @@ -87,7 +88,17 @@ public function store(StorePayoutRequest $request): VoucherTransactionPayoutReso } } - if (VoucherPayoutAmountRule::exceedsBalance($amount, (float) $voucher->amount_available)) { + $partialAmounts = $voucher->getPayoutPartialAmounts(); + + if (is_array($partialAmounts)) { + $allowedCents = array_map(fn (string $amount) => Number::toCents((float) $amount), $partialAmounts); + + if (!in_array(Number::toCents($amount), $allowedCents, true)) { + throw ValidationException::withMessages([ + 'amount' => [trans('validation.payout.amount_partial')], + ]); + } + } elseif (VoucherPayoutAmountRule::exceedsBalance($amount, (float) $voucher->amount_available)) { throw ValidationException::withMessages([ 'amount' => [VoucherPayoutAmountRule::balanceExceededMessage($voucher)], ]); diff --git a/app/Http/Controllers/Api/Platform/VouchersController.php b/app/Http/Controllers/Api/Platform/VouchersController.php index 4570a13fa..a26547c31 100644 --- a/app/Http/Controllers/Api/Platform/VouchersController.php +++ b/app/Http/Controllers/Api/Platform/VouchersController.php @@ -76,7 +76,7 @@ public function sendEmail(Voucher $voucher): JsonResponse { $this->authorize('sendEmail', $voucher); - $voucher->sendToEmail($voucher->identity->email); + $voucher->sendToEmail(); return new JsonResponse([]); } diff --git a/app/Http/Requests/Api/Platform/Organizations/Vouchers/SendVoucherRequest.php b/app/Http/Requests/Api/Platform/Organizations/Vouchers/SendVoucherRequest.php index 39e0eba74..0fac9d456 100644 --- a/app/Http/Requests/Api/Platform/Organizations/Vouchers/SendVoucherRequest.php +++ b/app/Http/Requests/Api/Platform/Organizations/Vouchers/SendVoucherRequest.php @@ -23,8 +23,7 @@ public function authorize(): bool { return $this->organization->identityCan($this->identity(), Permission::MANAGE_VOUCHERS) && - $this->voucher->fund->organization_id === $this->organization->id && - !$this->voucher->granted; + $this->voucher->fund->organization_id === $this->organization->id; } /** @@ -36,7 +35,7 @@ public function rules(): array { return [ 'email' => [ - 'required', + $this->voucher->granted ? 'nullable' : 'required', new IdentityEmailExistsRule(), ...$this->emailRules(), ], diff --git a/app/Http/Resources/FundResource.php b/app/Http/Resources/FundResource.php index a881a1076..2f76bc7e9 100644 --- a/app/Http/Resources/FundResource.php +++ b/app/Http/Resources/FundResource.php @@ -205,7 +205,7 @@ protected function getFundConfigData(Fund $fund, bool $isDashboard): array 'key', 'allow_fund_requests', 'allow_fund_request_prefill', 'allow_prevalidations', 'allow_direct_requests', 'allow_blocking_vouchers', 'backoffice_fallback', 'is_configured', 'email_required', 'contact_info_enabled', 'contact_info_required', 'allow_reimbursements', - 'allow_voucher_payouts', 'allow_voucher_payout_count', + 'allow_voucher_payouts', 'allow_voucher_payouts_partial', 'allow_voucher_payout_count', 'contact_info_message_custom', 'contact_info_message_text', 'bsn_confirmation_time', 'auth_2fa_policy', 'auth_2fa_remember_ip', 'auth_2fa_restrict_reimbursements', 'auth_2fa_restrict_auth_sessions', 'auth_2fa_restrict_emails', diff --git a/app/Http/Resources/Sponsor/SponsorVoucherResource.php b/app/Http/Resources/Sponsor/SponsorVoucherResource.php index bb43cac05..1616155e3 100644 --- a/app/Http/Resources/Sponsor/SponsorVoucherResource.php +++ b/app/Http/Resources/Sponsor/SponsorVoucherResource.php @@ -91,6 +91,7 @@ public function toArray(Request $request): array 'url_webshop' => $voucher->fund->fund_config->implementation->url_webshop ?? null, 'show_subsidies' => $voucher->fund->fund_config->show_subsidies ?? false, 'show_qr_limits' => $voucher->fund->fund_config->show_qr_limits ?? false, + 'show_qr_code' => $voucher->fund->fund_config->show_qr_code ?? false, 'show_requester_limits' => $voucher->fund->fund_config->show_requester_limits ?? false, 'allow_physical_cards' => $voucher->fund->fund_config->allow_physical_cards ?? false, 'allow_voucher_records' => $voucher->fund->fund_config->allow_voucher_records ?? false, diff --git a/app/Http/Resources/VoucherResource.php b/app/Http/Resources/VoucherResource.php index 60ee30d8e..72d1499eb 100644 --- a/app/Http/Resources/VoucherResource.php +++ b/app/Http/Resources/VoucherResource.php @@ -107,6 +107,7 @@ public function toArray(Request $request): array 'iban' => $voucher->fund_request->getIban(false), 'iban_name' => $voucher->fund_request->getIbanName(false), ] : null, + 'voucher_payout_partial_amounts' => $voucher->getPayoutPartialAmounts(), ...$this->getRecords($voucher), ...$this->timestamps($voucher, 'created_at'), ]; @@ -274,7 +275,7 @@ protected function getFundResource(Voucher $voucher): array 'fund_physical_card_types' => FundPhysicalCardTypeResource::collection($fund->fund_physical_card_types), ...$fund->fund_config->only([ 'allow_reimbursements', 'allow_reservations', 'key', 'show_qr_code', - 'allow_voucher_payouts', 'allow_voucher_payout_count', + 'allow_voucher_payouts', 'allow_voucher_payouts_partial', 'allow_voucher_payout_count', ]), 'voucher_payout_fixed_amount' => $payoutAmount === null ? null : currency_format($payoutAmount), ]; diff --git a/app/Listeners/VoucherSubscriber.php b/app/Listeners/VoucherSubscriber.php index 815e06f50..dad6da1ad 100644 --- a/app/Listeners/VoucherSubscriber.php +++ b/app/Listeners/VoucherSubscriber.php @@ -12,14 +12,17 @@ use App\Events\Vouchers\VoucherExpireSoon; use App\Events\Vouchers\VoucherLimitUpdated; use App\Events\Vouchers\VoucherPhysicalCardRequestedEvent; +use App\Events\Vouchers\VoucherSendToEmailBySponsorEvent; use App\Events\Vouchers\VoucherSendToEmailEvent; -use App\Mail\Vouchers\SendVoucherMail; use App\Models\Voucher; use App\Models\VoucherToken; use App\Notifications\Identities\Voucher\IdentityProductVoucherAddedNotification; use App\Notifications\Identities\Voucher\IdentityProductVoucherExpiredNotification; use App\Notifications\Identities\Voucher\IdentityProductVoucherReservedNotification; +use App\Notifications\Identities\Voucher\IdentityProductVoucherSharedByEmailNotification; use App\Notifications\Identities\Voucher\IdentityProductVoucherSharedNotification; +use App\Notifications\Identities\Voucher\IdentitySponsorProductVoucherSharedByEmailNotification; +use App\Notifications\Identities\Voucher\IdentitySponsorVoucherSharedByEmailNotification; use App\Notifications\Identities\Voucher\IdentityVoucherAddedBudgetNotification; use App\Notifications\Identities\Voucher\IdentityVoucherAssignedBudgetNotification; use App\Notifications\Identities\Voucher\IdentityVoucherAssignedProductNotification; @@ -271,31 +274,61 @@ public function onVoucherDeactivated(VoucherDeactivated $voucherDeactivated): vo } /** - * @param VoucherSendToEmailEvent $event + * @param VoucherSendToEmailBySponsorEvent $event * @noinspection PhpUnused */ - public function onVoucherSendToEmail(VoucherSendToEmailEvent $event): void + public function onVoucherSendToEmailBySponsor(VoucherSendToEmailBySponsorEvent $event): void { $email = $event->getEmail(); $voucher = $event->getVoucher(); $eventLog = $voucher->log($voucher::EVENT_SHARED_BY_EMAIL, [ 'fund' => $voucher->fund, + 'voucher' => $voucher, + 'product' => $voucher->product, 'sponsor' => $voucher->fund->organization, + 'provider' => $voucher->product?->organization, 'implementation' => $voucher->fund->getImplementation(), ], [ + 'email' => $email, 'qr_token' => $voucher->fund->fund_config->show_qr_code ? $voucher->token_without_confirmation->address : null, - 'voucher_product_or_fund_name' => $voucher->product->name ?? $voucher->fund->name, + 'expiration_date' => format_date_locale($voucher->last_active_day), ]); - IdentityVoucherSharedByEmailNotification::send($eventLog); + if ($voucher->product) { + IdentitySponsorProductVoucherSharedByEmailNotification::send($eventLog); + } else { + IdentitySponsorVoucherSharedByEmailNotification::send($eventLog); + } + } + + /** + * @param VoucherSendToEmailEvent $event + * @noinspection PhpUnused + */ + public function onVoucherSendToEmailByIdentity(VoucherSendToEmailEvent $event): void + { + $voucher = $event->getVoucher(); + + $eventLog = $voucher->log($voucher::EVENT_SHARED_BY_EMAIL, [ + 'fund' => $voucher->fund, + 'product' => $voucher->product, + 'sponsor' => $voucher->fund->organization, + 'provider' => $voucher->product?->organization, + 'implementation' => $voucher->fund->getImplementation(), + ], [ + 'qr_token' => $voucher->fund->fund_config->show_qr_code + ? $voucher->token_without_confirmation->address + : null, + ]); - resolve('forus.services.notification')->sendSystemMail($email, new SendVoucherMail( - $eventLog->data, - $voucher->fund->getEmailFrom() - )); + if ($voucher->product) { + IdentityProductVoucherSharedByEmailNotification::send($eventLog); + } else { + IdentityVoucherSharedByEmailNotification::send($eventLog); + } } /** @@ -350,7 +383,8 @@ public function subscribe(Dispatcher $events): void $events->listen(VoucherExpireSoon::class, "$class@onVoucherExpireSoon"); $events->listen(VoucherExpired::class, "$class@onVoucherExpired"); $events->listen(VoucherDeactivated::class, "$class@onVoucherDeactivated"); - $events->listen(VoucherSendToEmailEvent::class, "$class@onVoucherSendToEmail"); + $events->listen(VoucherSendToEmailEvent::class, "$class@onVoucherSendToEmailByIdentity"); + $events->listen(VoucherSendToEmailBySponsorEvent::class, "$class@onVoucherSendToEmailBySponsor"); $events->listen(VoucherPhysicalCardRequestedEvent::class, "$class@onVoucherPhysicalCardRequested"); } } diff --git a/app/Mail/Funds/FundRequests/FundRequestApprovedMail.php b/app/Mail/Funds/FundRequests/FundRequestApprovedMail.php index dc02ba4d5..a5ed4a10b 100644 --- a/app/Mail/Funds/FundRequests/FundRequestApprovedMail.php +++ b/app/Mail/Funds/FundRequests/FundRequestApprovedMail.php @@ -28,7 +28,7 @@ protected function getMailExtraData(array $data): array return [ 'app_link' => $this->makeLink($data['app_link'], 'download de Me-app'), 'webshop_link' => $this->makeLink($data['webshop_link'], 'hier'), - 'webshop_button' => $this->makeButton($data['webshop_link'], 'Activeer tegoed'), + 'webshop_button' => $this->makeButton($data['webshop_link'], 'Ga naar webshop'), ]; } } diff --git a/app/Mail/Reimbursements/ReimbursementApprovedMail.php b/app/Mail/Reimbursements/ReimbursementApprovedMail.php index 733636ec1..fedbfbba8 100644 --- a/app/Mail/Reimbursements/ReimbursementApprovedMail.php +++ b/app/Mail/Reimbursements/ReimbursementApprovedMail.php @@ -28,7 +28,7 @@ protected function getMailExtraData(array $data): array return [ 'app_link' => $this->makeLink($data['app_link'], 'download de Me-app'), 'webshop_link' => $this->makeLink($data['webshop_link'], 'hier'), - 'webshop_button' => $this->makeButton($data['webshop_link'], 'Activeer tegoed'), + 'webshop_button' => $this->makeButton($data['webshop_link'], 'Ga naar webshop'), ]; } } diff --git a/app/Mail/Vouchers/SendProductVoucherBySponsorMail.php b/app/Mail/Vouchers/SendProductVoucherBySponsorMail.php new file mode 100644 index 000000000..db4fa77a3 --- /dev/null +++ b/app/Mail/Vouchers/SendProductVoucherBySponsorMail.php @@ -0,0 +1,32 @@ +buildNotificationTemplatedMail(); + } + + /** + * @param array $data + * @return array + */ + protected function getMailExtraData(array $data): array + { + return [ + ...$data['qr_token'] ? ['qr_token' => $this->makeQrCode($data['qr_token'])] : [], + ]; + } +} diff --git a/app/Mail/Vouchers/SendProductVoucherMail.php b/app/Mail/Vouchers/SendProductVoucherMail.php new file mode 100644 index 000000000..ce09c0dd2 --- /dev/null +++ b/app/Mail/Vouchers/SendProductVoucherMail.php @@ -0,0 +1,32 @@ +buildNotificationTemplatedMail(); + } + + /** + * @param array $data + * @return array + */ + protected function getMailExtraData(array $data): array + { + return [ + ...$data['qr_token'] ? ['qr_token' => $this->makeQrCode($data['qr_token'])] : [], + ]; + } +} diff --git a/app/Mail/Vouchers/SendVoucherBySponsorMail.php b/app/Mail/Vouchers/SendVoucherBySponsorMail.php new file mode 100644 index 000000000..1a3eb4e62 --- /dev/null +++ b/app/Mail/Vouchers/SendVoucherBySponsorMail.php @@ -0,0 +1,32 @@ +buildNotificationTemplatedMail(); + } + + /** + * @param array $data + * @return array + */ + protected function getMailExtraData(array $data): array + { + return [ + ...$data['qr_token'] ? ['qr_token' => $this->makeQrCode($data['qr_token'])] : [], + ]; + } +} diff --git a/app/Mail/Vouchers/SendVoucherMail.php b/app/Mail/Vouchers/SendVoucherMail.php index 1d51695a3..5a74e54d4 100644 --- a/app/Mail/Vouchers/SendVoucherMail.php +++ b/app/Mail/Vouchers/SendVoucherMail.php @@ -8,7 +8,7 @@ class SendVoucherMail extends ImplementationMail { - public $subject = 'Hierbij ontvangt u uw :fund_name'; + public ?string $notificationTemplateKey = 'notifications_identities.voucher_shared_by_email'; /** * @throws CommonMarkException @@ -16,7 +16,7 @@ class SendVoucherMail extends ImplementationMail */ public function build(): Mailable { - return parent::buildSystemMail('voucher_send_to_email'); + return $this->buildNotificationTemplatedMail(); } /** diff --git a/app/Models/Fund.php b/app/Models/Fund.php index 4b58d3f1c..906c83f11 100644 --- a/app/Models/Fund.php +++ b/app/Models/Fund.php @@ -1046,12 +1046,22 @@ public function voucherPayoutAmountForIdentity(?Identity $identity, bool $fresh return 0.0; } - $record = $identity - ? $this->getTrustedRecordOfType($identity, $formula->record_type_key) + $value = $identity + ? $this->getTrustedRecordOfType($identity, $formula->record_type_key)?->value : null; - $value = $record?->value; - return is_numeric($value) ? (float) $formula->amount * (float) $value : 0.0; + if (!is_numeric($value)) { + return 0.0; + } + + $amount = (float) $formula->amount * (float) $value; + $maxAmount = $formula->getMaxAmount(); + + if ($maxAmount <= 0) { + return 0.0; + } + + return min($amount, $maxAmount); default: return 0.0; } diff --git a/app/Models/FundConfig.php b/app/Models/FundConfig.php index 8854e9a38..fa21b5aab 100644 --- a/app/Models/FundConfig.php +++ b/app/Models/FundConfig.php @@ -47,6 +47,7 @@ * @property bool $allow_reservations * @property bool $allow_reimbursements * @property bool $allow_voucher_payouts + * @property bool $allow_voucher_payouts_partial * @property int|null $allow_voucher_payout_count * @property bool $allow_direct_payments * @property bool $allow_generator_direct_payments @@ -318,6 +319,7 @@ class FundConfig extends Model 'allow_reservations' => 'boolean', 'allow_reimbursements' => 'boolean', 'allow_voucher_payouts' => 'boolean', + 'allow_voucher_payouts_partial' => 'boolean', 'limit_generator_amount' => 'string', 'limit_voucher_top_up_amount' => 'string', 'limit_voucher_total_amount' => 'string', diff --git a/app/Models/FundPayoutFormula.php b/app/Models/FundPayoutFormula.php index add57d601..65ebe6df1 100644 --- a/app/Models/FundPayoutFormula.php +++ b/app/Models/FundPayoutFormula.php @@ -12,6 +12,7 @@ * @property int $fund_id * @property string $type * @property string $amount + * @property string|null $max_amount * @property string|null $record_type_key * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at @@ -35,9 +36,10 @@ class FundPayoutFormula extends Model { public const string TYPE_FIXED = 'fixed'; public const string TYPE_MULTIPLY = 'multiply'; + public const float MULTIPLY_MAX_AMOUNT = 5000.0; protected $fillable = [ - 'id', 'fund_id', 'type', 'amount', 'record_type_key', + 'id', 'fund_id', 'type', 'amount', 'max_amount', 'record_type_key', ]; /** @@ -78,4 +80,12 @@ public function record_type(): BelongsTo { return $this->belongsTo(RecordType::class, 'record_type_key', 'key'); } + + /** + * @return float + */ + public function getMaxAmount(): float + { + return $this->max_amount !== null ? (float) $this->max_amount : self::MULTIPLY_MAX_AMOUNT; + } } diff --git a/app/Models/Voucher.php b/app/Models/Voucher.php index d894ae3e0..d3a9408f2 100644 --- a/app/Models/Voucher.php +++ b/app/Models/Voucher.php @@ -14,6 +14,7 @@ use App\Events\Vouchers\VoucherSendToEmailEvent; use App\Events\VoucherTransactions\VoucherTransactionCreated; use App\Exports\VoucherExport; +use App\Helpers\Number; use App\Models\Data\VoucherExportData; use App\Models\Traits\HasDbTokens; use App\Models\Traits\HasFormattedTimestamps; @@ -459,6 +460,61 @@ public function requester_payouts(): HasMany ->where('initiator', VoucherTransaction::INITIATOR_REQUESTER); } + /** + * @return array|null + */ + public function getPayoutPartialAmounts(): ?array + { + // check that voucher payouts and allowed and partial payouts enabled + if (!$this->fund?->fund_config?->allow_voucher_payouts || + !$this->fund?->fund_config?->allow_voucher_payouts_partial) { + return null; + } + + // check that has exactly one payout formula and is of multiply type + if ($this->fund?->fund_payout_formulas?->count() !== 1 || + $this->fund->fund_payout_formulas[0]->type !== FundPayoutFormula::TYPE_MULTIPLY) { + return null; + } + + $formula = $this->fund->fund_payout_formulas[0]; + $unitCents = Number::toCents((float) $formula->amount); + + if ($unitCents <= 0) { + return []; + } + + $recordValue = 0; + + if ($formula->record_type_key && $this->identity) { + $record = $this->fund->getTrustedRecordOfType($this->identity, $formula->record_type_key); + $recordValue = is_numeric($record?->value) ? (int) $record->value : 0; + } + + $amountCents = Number::toCents(((float) $formula->amount) * $recordValue); + $maxCents = Number::toCents($formula->getMaxAmount()); + $totalCents = $maxCents <= 0 ? 0 : min($amountCents, $maxCents); + + $requesterPayoutsAmount = (float) $this->requester_payouts() + ->selectRaw('IFNULL(SUM(IFNULL(amount_voucher, amount)), 0) as total') + ->value('total'); + + $totalCents = max(0, $totalCents); + $remainingCents = max(0, $totalCents - Number::toCents($requesterPayoutsAmount)); + + $balanceCents = Number::toCents((float) $this->amount_available); + $remainingCents = min($remainingCents, $balanceCents); + + if ($remainingCents < $unitCents) { + return []; + } + + return array_map( + fn (int $count) => currency_format(($unitCents * $count) / 100), + range(1, intdiv($remainingCents, $unitCents)), + ); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany * @noinspection PhpUnused @@ -787,11 +843,11 @@ public function getLastActiveDayAttribute(): ?Carbon } /** - * @param string|null $email + * @return void */ - public function sendToEmail(string $email = null): void + public function sendToEmail(): void { - VoucherSendToEmailEvent::dispatch($this, $email); + Event::dispatch(new VoucherSendToEmailEvent($this)); } /** diff --git a/app/Notifications/BaseNotification.php b/app/Notifications/BaseNotification.php index dd782da14..da6c831dc 100644 --- a/app/Notifications/BaseNotification.php +++ b/app/Notifications/BaseNotification.php @@ -188,7 +188,18 @@ abstract class BaseNotification extends Notification implements ShouldQueue 'address', 'city', 'fund_name', 'house', 'house_addition', 'postcode', 'sponsor_email', 'sponsor_phone', ], - 'notifications_identities.voucher_shared_by_email' => [], + 'notifications_identities.voucher_shared_by_email' => [ + 'fund_name', + ], + 'notifications_identities.product_voucher_shared_by_email' => [ + 'fund_name', 'product_name', 'provider_name', + ], + 'notifications_identities.sponsor_voucher_shared_by_email' => [ + 'fund_name', + ], + 'notifications_identities.sponsor_product_voucher_shared_by_email' => [ + 'fund_name', 'product_name', 'provider_name', + ], 'notifications_identities.voucher_budget_transaction' => [ 'amount', 'fund_name', 'voucher_amount_locale', ], diff --git a/app/Notifications/Identities/Voucher/IdentityProductVoucherSharedByEmailNotification.php b/app/Notifications/Identities/Voucher/IdentityProductVoucherSharedByEmailNotification.php new file mode 100644 index 000000000..85c806ed1 --- /dev/null +++ b/app/Notifications/Identities/Voucher/IdentityProductVoucherSharedByEmailNotification.php @@ -0,0 +1,25 @@ +eventLog->data, Implementation::emailFrom()); + $this->sendMailNotification($identity->email, $mailable, $this->eventLog); + } +} diff --git a/app/Notifications/Identities/Voucher/IdentitySponsorProductVoucherSharedByEmailNotification.php b/app/Notifications/Identities/Voucher/IdentitySponsorProductVoucherSharedByEmailNotification.php new file mode 100644 index 000000000..d3fb77925 --- /dev/null +++ b/app/Notifications/Identities/Voucher/IdentitySponsorProductVoucherSharedByEmailNotification.php @@ -0,0 +1,51 @@ +identity_id && Arr::get($eventLog->data, 'email')) { + return collect([Identity::findByEmail(Arr::get($eventLog->data, 'email'))]); + } + + return Identity::where('id', $loggable->identity_id)->get(); + } + + /** + * @param Identity $identity + */ + public function toMail(Identity $identity): void + { + /** @var Voucher $voucher */ + $voucher = $this->eventLog->loggable; + + $mailable = new SendProductVoucherBySponsorMail( + $this->eventLog->data, + $voucher->fund->fund_config->implementation->getEmailFrom() + ); + + $this->sendMailNotification($identity->email, $mailable, $this->eventLog); + } +} diff --git a/app/Notifications/Identities/Voucher/IdentitySponsorVoucherSharedByEmailNotification.php b/app/Notifications/Identities/Voucher/IdentitySponsorVoucherSharedByEmailNotification.php new file mode 100644 index 000000000..ac2272eab --- /dev/null +++ b/app/Notifications/Identities/Voucher/IdentitySponsorVoucherSharedByEmailNotification.php @@ -0,0 +1,51 @@ +identity_id && Arr::get($eventLog->data, 'email')) { + return collect([Identity::findByEmail(Arr::get($eventLog->data, 'email'))]); + } + + return Identity::where('id', $loggable->identity_id)->get(); + } + + /** + * @param Identity $identity + */ + public function toMail(Identity $identity): void + { + /** @var Voucher $voucher */ + $voucher = $this->eventLog->loggable; + + $mailable = new SendVoucherBySponsorMail( + $this->eventLog->data, + $voucher->fund->fund_config->implementation->getEmailFrom() + ); + + $this->sendMailNotification($identity->email, $mailable, $this->eventLog); + } +} diff --git a/app/Notifications/Identities/Voucher/IdentityVoucherSharedByEmailNotification.php b/app/Notifications/Identities/Voucher/IdentityVoucherSharedByEmailNotification.php index e754224d0..f0286bf6c 100644 --- a/app/Notifications/Identities/Voucher/IdentityVoucherSharedByEmailNotification.php +++ b/app/Notifications/Identities/Voucher/IdentityVoucherSharedByEmailNotification.php @@ -2,6 +2,10 @@ namespace App\Notifications\Identities\Voucher; +use App\Mail\Vouchers\SendVoucherMail; +use App\Models\Identity; +use App\Models\Implementation; + /** * Send voucher to owner's email. */ @@ -9,4 +13,13 @@ class IdentityVoucherSharedByEmailNotification extends BaseIdentityVoucherNotifi { protected static ?string $key = 'notifications_identities.voucher_shared_by_email'; protected static ?string $scope = null; + + /** + * @param Identity $identity + */ + public function toMail(Identity $identity): void + { + $mailable = new SendVoucherMail($this->eventLog->data, Implementation::emailFrom()); + $this->sendMailNotification($identity->email, $mailable, $this->eventLog); + } } diff --git a/app/Policies/VoucherPolicy.php b/app/Policies/VoucherPolicy.php index ee55df8ec..1387f21b5 100644 --- a/app/Policies/VoucherPolicy.php +++ b/app/Policies/VoucherPolicy.php @@ -249,7 +249,12 @@ public function sendByEmailSponsor( Voucher $voucher, Organization $organization ): bool { - return $this->assignSponsor($identity, $voucher, $organization); + return + $organization->identityCan($identity, Permission::MANAGE_VOUCHERS) && + $voucher->fund->organization_id === $organization->id && + $voucher->fund->isConfigured() && + !$voucher->fund->external && + !$voucher->deactivated; } /** diff --git a/app/Rules/Payouts/VoucherPayoutAmountRule.php b/app/Rules/Payouts/VoucherPayoutAmountRule.php index d77bf5bc8..20e7f9f23 100644 --- a/app/Rules/Payouts/VoucherPayoutAmountRule.php +++ b/app/Rules/Payouts/VoucherPayoutAmountRule.php @@ -2,6 +2,7 @@ namespace App\Rules\Payouts; +use App\Helpers\Number; use App\Models\Voucher; use App\Rules\BaseRule; @@ -29,13 +30,25 @@ public function passes($attribute, $value): bool return true; } + $partialAmounts = $this->voucher->getPayoutPartialAmounts(); + + if (is_array($partialAmounts)) { + $allowedCents = array_map(fn (string $amount) => Number::toCents((float) $amount), $partialAmounts); + + if (!in_array(Number::toCents((float) $value), $allowedCents, true)) { + return $this->reject(trans('validation.payout.amount_partial')); + } + + return true; + } + $fixedAmount = $this->voucher->fund?->voucherPayoutAmountForIdentity($this->voucher->identity); $balance = (float) $this->voucher->amount_available; - $balanceCents = self::toCents($balance); - $amountCents = self::toCents((float) $value); + $balanceCents = Number::toCents($balance); + $amountCents = Number::toCents((float) $value); if ($fixedAmount !== null) { - $fixedAmountCents = self::toCents($fixedAmount); + $fixedAmountCents = Number::toCents($fixedAmount); if ($amountCents !== $fixedAmountCents) { return $this->reject(trans('validation.payout.amount_exact', [ @@ -50,7 +63,7 @@ public function passes($attribute, $value): bool return true; } - $minAmountCents = self::toCents($minAmount); + $minAmountCents = Number::toCents($minAmount); if ($amountCents < $minAmountCents || $amountCents > $balanceCents) { return $this->reject(trans('validation.payout.amount_between', [ @@ -62,15 +75,6 @@ public function passes($attribute, $value): bool return true; } - /** - * @param float $amount - * @return int - */ - public static function toCents(float $amount): int - { - return (int) round($amount * 100); - } - /** * @param float $amount * @param float $balance @@ -78,7 +82,7 @@ public static function toCents(float $amount): int */ public static function exceedsBalance(float $amount, float $balance): bool { - return self::toCents($amount) > self::toCents($balance); + return Number::toCents($amount) > Number::toCents($balance); } /** diff --git a/app/Scopes/Builders/EmailLogQuery.php b/app/Scopes/Builders/EmailLogQuery.php index 82b1fe7e3..0b9a726c7 100644 --- a/app/Scopes/Builders/EmailLogQuery.php +++ b/app/Scopes/Builders/EmailLogQuery.php @@ -16,6 +16,10 @@ use App\Mail\Vouchers\DeactivationVoucherMail; use App\Mail\Vouchers\PaymentSuccessBudgetMail; use App\Mail\Vouchers\RequestPhysicalCardMail; +use App\Mail\Vouchers\SendProductVoucherBySponsorMail; +use App\Mail\Vouchers\SendProductVoucherMail; +use App\Mail\Vouchers\SendVoucherBySponsorMail; +use App\Mail\Vouchers\SendVoucherMail; use App\Mail\Vouchers\VoucherAssignedBudgetMail; use App\Mail\Vouchers\VoucherAssignedProductMail; use App\Mail\Vouchers\VoucherExpireSoonBudgetMail; @@ -80,6 +84,10 @@ public static function whereIdentity( VoucherExpireSoonBudgetMail::class, RequestPhysicalCardMail::class, PaymentSuccessBudgetMail::class, + SendVoucherMail::class, + SendProductVoucherMail::class, + SendVoucherBySponsorMail::class, + SendProductVoucherBySponsorMail::class, // ProductReservation ProductReservationAcceptedMail::class, diff --git a/app/Services/Forus/TestData/TestData.php b/app/Services/Forus/TestData/TestData.php index 848847b6f..64e6bf536 100644 --- a/app/Services/Forus/TestData/TestData.php +++ b/app/Services/Forus/TestData/TestData.php @@ -592,6 +592,7 @@ public function makeImplementation( 'page_type' => $type, 'description' => $length > 1000 ? $generator->generate($length) : $faker->text(rand($length / 2, $length)), 'description_alignment' => $type == ImplementationPage::TYPE_HOME ? 'center' : 'left', + 'blocks_per_row' => 3, ]); } diff --git a/database/migrations/2026_03_05_004285_add_allow_voucher_payouts_partial_to_fund_configs_table.php b/database/migrations/2026_03_05_004285_add_allow_voucher_payouts_partial_to_fund_configs_table.php new file mode 100644 index 000000000..e9c212508 --- /dev/null +++ b/database/migrations/2026_03_05_004285_add_allow_voucher_payouts_partial_to_fund_configs_table.php @@ -0,0 +1,30 @@ +boolean('allow_voucher_payouts_partial') + ->default(false) + ->after('allow_voucher_payouts'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('fund_configs', function (Blueprint $table) { + $table->dropColumn('allow_voucher_payouts_partial'); + }); + } +}; diff --git a/database/migrations/2026_03_05_004895_add_max_amount_to_fund_payout_formulas_table.php b/database/migrations/2026_03_05_004895_add_max_amount_to_fund_payout_formulas_table.php new file mode 100644 index 000000000..4d1cdd902 --- /dev/null +++ b/database/migrations/2026_03_05_004895_add_max_amount_to_fund_payout_formulas_table.php @@ -0,0 +1,27 @@ +decimal('max_amount', 10)->unsigned()->nullable()->after('amount'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('fund_payout_formulas', function (Blueprint $table) { + $table->dropColumn('max_amount'); + }); + } +}; diff --git a/database/seeders/SystemNotificationsTableSeeder.php b/database/seeders/SystemNotificationsTableSeeder.php index b2c4a59ca..b3793051a 100644 --- a/database/seeders/SystemNotificationsTableSeeder.php +++ b/database/seeders/SystemNotificationsTableSeeder.php @@ -80,7 +80,10 @@ class SystemNotificationsTableSeeder extends Seeder 'notifications_identities.voucher_expire_soon_budget' => ['database', 'mail'], 'notifications_identities.voucher_expire_soon_product' => [], // TODO can database notifications be removed 'notifications_identities.voucher_physical_card_requested' => ['database', 'mail'], - 'notifications_identities.voucher_shared_by_email' => ['database'], + 'notifications_identities.voucher_shared_by_email' => ['mail'], + 'notifications_identities.product_voucher_shared_by_email' => ['mail'], + 'notifications_identities.sponsor_voucher_shared_by_email' => ['mail'], + 'notifications_identities.sponsor_product_voucher_shared_by_email' => ['mail'], 'notifications_identities.voucher_budget_transaction' => ['database', 'mail', 'push'], 'notifications_identities.product_voucher_transaction' => ['database', 'push'], @@ -149,6 +152,11 @@ class SystemNotificationsTableSeeder extends Seeder 'notifications_identities.product_voucher_transaction', 'notifications_identities.voucher_budget_transaction', + 'notifications_identities.voucher_shared_by_email', + 'notifications_identities.product_voucher_shared_by_email', + 'notifications_identities.sponsor_voucher_shared_by_email', + 'notifications_identities.sponsor_product_voucher_shared_by_email', + 'notifications_fund_providers.bunq_transaction_success', ]; @@ -174,6 +182,11 @@ class SystemNotificationsTableSeeder extends Seeder 'notifications_identities.voucher_expire_soon_budget', 'notifications_identities.voucher_expire_soon_product', 'notifications_identities.voucher_physical_card_requested', + + 'notifications_identities.voucher_shared_by_email', + 'notifications_identities.product_voucher_shared_by_email', + 'notifications_identities.sponsor_voucher_shared_by_email', + 'notifications_identities.sponsor_product_voucher_shared_by_email', ]; protected array $notificationGroups = [ @@ -199,6 +212,10 @@ class SystemNotificationsTableSeeder extends Seeder 'notifications_identities.voucher_expire_soon_product', 'notifications_identities.product_voucher_expired', 'notifications_identities.voucher_physical_card_requested', + 'notifications_identities.voucher_shared_by_email', + 'notifications_identities.product_voucher_shared_by_email', + 'notifications_identities.sponsor_voucher_shared_by_email', + 'notifications_identities.sponsor_product_voucher_shared_by_email', ], 'requester_transactions' => [ 'notifications_identities.requester_provider_approved_products', @@ -210,7 +227,6 @@ class SystemNotificationsTableSeeder extends Seeder 'notifications_identities.product_voucher_shared', 'notifications_identities.product_reservation_rejected', 'notifications_identities.product_reservation_canceled', - 'notifications_identities.voucher_shared_by_email', 'notifications_identities.voucher_budget_transaction', 'notifications_identities.product_voucher_transaction', ], diff --git a/database/seeders/resources/mail_templates/notification_templates.json b/database/seeders/resources/mail_templates/notification_templates.json index 78e7e8d67..779da6acb 100644 --- a/database/seeders/resources/mail_templates/notification_templates.json +++ b/database/seeders/resources/mail_templates/notification_templates.json @@ -653,7 +653,42 @@ } }, "notifications_identities.voucher_shared_by_email": { - "mail": {}, + "mail": { + "title": "Hierbij ontvangt u uw :fund_name.", + "title_informal": "Hierbij ontvang je jouw :fund_name.", + "template": "notifications_identities/voucher_shared_by_email.md", + "template_informal": "notifications_identities/voucher_shared_by_email_informal.md" + }, + "push": {}, + "database": {} + }, + "notifications_identities.product_voucher_shared_by_email": { + "mail": { + "title": "Hierbij ontvangt u uw :product_name.", + "title_informal": "Hierbij ontvang je jouw :product_name.", + "template": "notifications_identities/product_voucher_shared_by_email.md", + "template_informal": "notifications_identities/product_voucher_shared_by_email_informal.md" + }, + "push": {}, + "database": {} + }, + "notifications_identities.sponsor_voucher_shared_by_email": { + "mail": { + "title": "Hierbij ontvangt u uw :fund_name.", + "title_informal": "Hierbij ontvang je jouw :fund_name.", + "template": "notifications_identities/sponsor_voucher_shared_by_email.md", + "template_informal": "notifications_identities/sponsor_voucher_shared_by_email_informal.md" + }, + "push": {}, + "database": {} + }, + "notifications_identities.sponsor_product_voucher_shared_by_email": { + "mail": { + "title": "Hierbij ontvangt u uw :product_name.", + "title_informal": "Hierbij ontvang je jouw :product_name.", + "template": "notifications_identities/sponsor_product_voucher_shared_by_email.md", + "template_informal": "notifications_identities/sponsor_product_voucher_shared_by_email_informal.md" + }, "push": {}, "database": {} }, diff --git a/resources/mail_templates/voucher_send_to_email.formal.md b/database/seeders/resources/mail_templates/notifications_identities/product_voucher_shared_by_email.md similarity index 76% rename from resources/mail_templates/voucher_send_to_email.formal.md rename to database/seeders/resources/mail_templates/notifications_identities/product_voucher_shared_by_email.md index d6761f1af..64a8ec6f4 100644 --- a/resources/mail_templates/voucher_send_to_email.formal.md +++ b/database/seeders/resources/mail_templates/notifications_identities/product_voucher_shared_by_email.md @@ -2,7 +2,7 @@ Beste deelnemer, -Hierbij ontvangt u uw :voucher_product_or_fund_name QR-code per e-mail. +Hierbij ontvangt u uw :fund_name QR-code per e-mail. Onderstaande QR-code laat u bij de aanbieder zien. :qr_token diff --git a/resources/mail_templates/voucher_send_to_email.informal.md b/database/seeders/resources/mail_templates/notifications_identities/product_voucher_shared_by_email_informal.md similarity index 77% rename from resources/mail_templates/voucher_send_to_email.informal.md rename to database/seeders/resources/mail_templates/notifications_identities/product_voucher_shared_by_email_informal.md index 05d86fb6f..9674d264a 100644 --- a/resources/mail_templates/voucher_send_to_email.informal.md +++ b/database/seeders/resources/mail_templates/notifications_identities/product_voucher_shared_by_email_informal.md @@ -2,7 +2,7 @@ Beste deelnemer, -Hierbij ontvang je :voucher_product_or_fund_name QR-code per e-mail. +Hierbij ontvang je :fund_name QR-code per e-mail. Onderstaande QR-code laat je bij de aanbieder zien. :qr_token diff --git a/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted.md b/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted.md index 79c8d723b..ad7020836 100644 --- a/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted.md +++ b/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted.md @@ -1,10 +1,13 @@ -# Uw :fund_name aanvraag is ontvangen +# Uw aanvraag is verstuurd -Beste aanvrager, +Beste deelnemer,   -:sponsor_name heeft uw :fund_name aanvraag ontvangen. Uw aanvraag is in behandeling. -U ontvangt zo snel mogelijk een reactie. +:sponsor_name heeft uw aanvraag voor het terugvragen van kosten ontvangen. +We bekijken uw aanvraag. U krijgt zo snel mogelijk een reactie van ons. +  + +Wilt u zien wat de status is van uw aanvraag? Klik dan op de knop hieronder.     diff --git a/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted_informal.md b/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted_informal.md index 6781155bd..f28ec865b 100644 --- a/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted_informal.md +++ b/database/seeders/resources/mail_templates/notifications_identities/reimbursement_submitted_informal.md @@ -1,11 +1,14 @@ -# Je :fund_name aanvraag is ontvangen +# Je aanvraag is verstuurd -Beste aanvrager, +Beste deelnemer,   -:sponsor_name heeft je :fund_name aanvraag ontvangen. -Je aanvraag is in behandeling. Je ontvangt zo snel mogelijk een reactie. +:sponsor_name heeft je aanvraag voor het terugvragen van kosten ontvangen. +We bekijken je aanvraag. Je krijgt zo snel mogelijk een reactie van ons.   + +Wil je zien wat de status is van je aanvraag? Klik dan op de knop hieronder. +    :webshop_button diff --git a/database/seeders/resources/mail_templates/notifications_identities/sponsor_product_voucher_shared_by_email.md b/database/seeders/resources/mail_templates/notifications_identities/sponsor_product_voucher_shared_by_email.md new file mode 100644 index 000000000..ff7a5b119 --- /dev/null +++ b/database/seeders/resources/mail_templates/notifications_identities/sponsor_product_voucher_shared_by_email.md @@ -0,0 +1,11 @@ +# Hierbij ontvangt u uw :product_name + +Beste deelnemer, + +Hierbij ontvangt u uw :product_name QR-code per e-mail. +Onderstaande QR-code laat u bij de aanbieder zien. + +:qr_token + +De aanbieder scant deze code en de betaling gebeurt automatisch. +Heeft u vragen over het aanbod? Neem dan contact op met :provider_name. \ No newline at end of file diff --git a/database/seeders/resources/mail_templates/notifications_identities/sponsor_product_voucher_shared_by_email_informal.md b/database/seeders/resources/mail_templates/notifications_identities/sponsor_product_voucher_shared_by_email_informal.md new file mode 100644 index 000000000..8d1ec9ed3 --- /dev/null +++ b/database/seeders/resources/mail_templates/notifications_identities/sponsor_product_voucher_shared_by_email_informal.md @@ -0,0 +1,11 @@ +# Hierbij ontvang je jouw :product_name + +Beste deelnemer, + +Hierbij ontvang je jouw :product_name QR-code per e-mail. +Onderstaande QR-code laat je bij de aanbieder zien. + +:qr_token + +De aanbieder scant deze code en de betaling gebeurt automatisch. +Heb je vragen over het aanbod? Neem dan contact op met :provider_name. \ No newline at end of file diff --git a/database/seeders/resources/mail_templates/notifications_identities/sponsor_voucher_shared_by_email.md b/database/seeders/resources/mail_templates/notifications_identities/sponsor_voucher_shared_by_email.md new file mode 100644 index 000000000..0e2e171ad --- /dev/null +++ b/database/seeders/resources/mail_templates/notifications_identities/sponsor_voucher_shared_by_email.md @@ -0,0 +1,16 @@ +# Hierbij ontvangt u uw :fund_name + +Beste deelnemer, +Hierbij ontvangt u uw :fund_name QR-code per e-mail. +Dit tegoed heeft een waarde van :voucher_amount_locale en is geldig tot en met :expiration_date. + +Laat onderstaande QR-code bij de aanbieder naar keuze zien. + +:qr_token + +De aanbieder scant deze code en de betaling gebeurt automatisch. + +Let op! Als u een aanbod afneemt met een geldbedrag lager dan :voucher_amount_locale blijft het restantbedrag op het tegoed staan. +Bewaar dit tegoed dus goed. + +Veel plezier met het gekozen aanbod. \ No newline at end of file diff --git a/database/seeders/resources/mail_templates/notifications_identities/sponsor_voucher_shared_by_email_informal.md b/database/seeders/resources/mail_templates/notifications_identities/sponsor_voucher_shared_by_email_informal.md new file mode 100644 index 000000000..fc5ba4456 --- /dev/null +++ b/database/seeders/resources/mail_templates/notifications_identities/sponsor_voucher_shared_by_email_informal.md @@ -0,0 +1,16 @@ +# Hierbij ontvang je jouw :fund_name + +Beste deelnemer, +Hierbij ontvangt je jouw :fund_name QR-code per e-mail. +Dit tegoed heeft een waarde van :voucher_amount_locale en is geldig tot en met :expiration_date. + +Laat onderstaande QR-code bij de aanbieder naar keuze zien. + +:qr_token + +De aanbieder scant deze code en de betaling gebeurt automatisch. + +Let op! Als je een aanbod afneemt met een geldbedrag lager dan :voucher_amount_locale blijft het restantbedrag op het tegoed staan. +Bewaar dit tegoed dus goed. + +Veel plezier met het gekozen aanbod. \ No newline at end of file diff --git a/database/seeders/resources/mail_templates/notifications_identities/voucher_shared_by_email.md b/database/seeders/resources/mail_templates/notifications_identities/voucher_shared_by_email.md new file mode 100644 index 000000000..64a8ec6f4 --- /dev/null +++ b/database/seeders/resources/mail_templates/notifications_identities/voucher_shared_by_email.md @@ -0,0 +1,14 @@ +# Hierbij ontvangt u uw :fund_name + +Beste deelnemer, + +Hierbij ontvangt u uw :fund_name QR-code per e-mail. +Onderstaande QR-code laat u bij de aanbieder zien. + +:qr_token + +De aanbieder scant deze code en de betaling gebeurt automatisch. +Veel plezier met de door u gekozen aanbieding. + + + diff --git a/database/seeders/resources/mail_templates/notifications_identities/voucher_shared_by_email_informal.md b/database/seeders/resources/mail_templates/notifications_identities/voucher_shared_by_email_informal.md new file mode 100644 index 000000000..9674d264a --- /dev/null +++ b/database/seeders/resources/mail_templates/notifications_identities/voucher_shared_by_email_informal.md @@ -0,0 +1,11 @@ +# Hierbij ontvang je :fund_name + +Beste deelnemer, + +Hierbij ontvang je :fund_name QR-code per e-mail. +Onderstaande QR-code laat je bij de aanbieder zien. + +:qr_token + +De aanbieder scant deze code en de betaling gebeurt automatisch. +Veel plezier met de door je gekozen aanbieding. \ No newline at end of file diff --git a/resources/lang/ar.json b/resources/lang/ar.json index 42fd27191..722206188 100644 --- a/resources/lang/ar.json +++ b/resources/lang/ar.json @@ -686,6 +686,10 @@ "passwords.sent": "لقد قمنا بإرسال رابط الاسترداد عبر البريد الإلكتروني", "passwords.token": "رمز استعادة كلمة المرور هذا غير صالح.", "passwords.user": "عنوان البريد الإلكتروني هذا غير معروف لنا", + "person_bsn_api.errors.connection_error": "خطأ في اتصال خدمة رقم الضمان الاجتماعي الشخصي.", + "person_bsn_api.errors.not_filled_required_criteria": "لم يتم استيفاء جميع المعايير المطلوبة بالتعبئة المسبقة", + "person_bsn_api.errors.not_found": "غير موجود", + "person_bsn_api.errors.taken_by_partner": "تم التقاطها بواسطة الشريك", "person_bsn_api.person_fields.address": "مكان الإقامة", "person_bsn_api.person_fields.age": "العمر", "person_bsn_api.person_fields.birth_date": "تاريخ الميلاد", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "انتهى وقت الدفع", "policies.reservations.not_waiting": "الدفع مقابل الحجز لا ينتظر.", "policies.reservations.timeout_extra_payment": "لا يمكن إلغاء الحجز في الوقت الحالي. يرجى محاولة :time.", + "prevalidation_requests.reasons.connection_error": "خطأ في الاتصال", + "prevalidation_requests.reasons.empty_prevalidations": "التحقق المسبق الفارغ", + "prevalidation_requests.reasons.invalid_records": "سجلات غير صالحة", + "prevalidation_requests.reasons.not_filled_required_criteria": "لم يتم استيفاء المعايير المطلوبة", + "prevalidation_requests.reasons.not_found": "غير موجود", + "prevalidation_requests.reasons.taken_by_partner": "مهام الشريك", "prices.discount": "الخصم: :amount_", "prices.discount_fixed": "خصم €", "prices.discount_percentage": "النسبة المئوية للخصم", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute__ يجب أن يكون ملفًا", "validation.filled": "يجب أن يحتوي الحقل :attribute_ على قيمة.", "validation.fund_request.extra_records": "تم تقديم سجلات إضافية غير مسموح بها.", + "validation.fund_request.group_required": "املأ واحدًا على الأقل من السجلات", + "validation.fund_request.invalid_prefill_value": ":attribute المحدد غير مسموح به في طلب التمويل هذا.", "validation.fund_request.invalid_record": "لا يُسمح باستخدام :attribute_ المحدد في تطبيق الصندوق هذا.", "validation.fund_request.required_record": "الحقل :attribute___ إلزامي لتطبيق الصندوق.", "validation.fund_request_request_eligible_field_incomplete": "الموافقة على الشروط والأحكام.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "الفئات الخاطئة.", "validation.payout.amount_between": "يجب أن يتراوح مبلغ الدفع بين :min و:max.", "validation.payout.amount_exact": "يجب أن يكون مبلغ الدفع بالضبط :amount.", + "validation.payout.amount_partial": "يجب أن يتطابق مبلغ الدفع مع الدفعة الجزئية المتاحة.", "validation.payout.count_reached": "تم الوصول إلى الحد الأقصى لعدد المدفوعات لهذا الرصيد (:count).", "validation.postcode": "يبدو أن :attribute_ غير صحيح.", "validation.present": "يجب أن يكون الحقل :attribute__ موجودًا.", diff --git a/resources/lang/de.json b/resources/lang/de.json index 69d68aa5f..ae7ee31eb 100644 --- a/resources/lang/de.json +++ b/resources/lang/de.json @@ -686,6 +686,10 @@ "passwords.sent": "Wir haben einen Wiederherstellungslink per E-Mail verschickt", "passwords.token": "Dieses Passwort-Wiederherstellungs-Token ist ungültig.", "passwords.user": "Diese E-Mail Adresse ist uns nicht bekannt", + "person_bsn_api.errors.connection_error": "Person BSN-Dienstverbindungsfehler.", + "person_bsn_api.errors.not_filled_required_criteria": "Nicht alle erforderlichen Kriterien mit Vorausfüllung ausgefüllt", + "person_bsn_api.errors.not_found": "Nicht gefunden", + "person_bsn_api.errors.taken_by_partner": "Wird vom Partner übernommen", "person_bsn_api.person_fields.address": "Aufenthaltsort", "person_bsn_api.person_fields.age": "Alter", "person_bsn_api.person_fields.birth_date": "Geburtsdatum", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Die Zahlungsfrist ist abgelaufen.", "policies.reservations.not_waiting": "Die Zahlung für die Reservierung wartet nicht.", "policies.reservations.timeout_extra_payment": "Es ist derzeit nicht möglich, Ihre Reservierung zu stornieren. Bitte versuchen Sie, :time.", + "prevalidation_requests.reasons.connection_error": "Verbindungsfehler", + "prevalidation_requests.reasons.empty_prevalidations": "Leere Vorvalidierungen", + "prevalidation_requests.reasons.invalid_records": "Ungültige Datensätze", + "prevalidation_requests.reasons.not_filled_required_criteria": "Nicht gefüllte erforderliche Kriterien", + "prevalidation_requests.reasons.not_found": "Nicht gefunden", + "prevalidation_requests.reasons.taken_by_partner": "Aufgaben des Partners", "prices.discount": "Rabatt: :amount", "prices.discount_fixed": "Rabatt €", "prices.discount_percentage": "Rabatt %", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute muss eine Datei sein", "validation.filled": "Das Feld :attribute muss einen Wert enthalten.", "validation.fund_request.extra_records": "Es wurden zusätzliche Datensätze eingereicht, die nicht zulässig sind.", + "validation.fund_request.group_required": "Füllen Sie mindestens einen der Datensätze aus.", + "validation.fund_request.invalid_prefill_value": "Das ausgewählte :attribute ist für diesen Förderantrag nicht zulässig.", "validation.fund_request.invalid_record": "Der ausgewählte :attribute ist für diesen Fondsantrag nicht zulässig.", "validation.fund_request.required_record": "Das Feld :attribute ist für den Fondsantrag obligatorisch.", "validation.fund_request_request_eligible_field_incomplete": "Akzeptieren Sie die Allgemeinen Geschäftsbedingungen.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Falsche Kategorien.", "validation.payout.amount_between": "Der Auszahlungsbetrag muss zwischen :min und :max liegen.", "validation.payout.amount_exact": "Der Auszahlungsbetrag muss genau :amount betragen.", + "validation.payout.amount_partial": "Der Auszahlungsbetrag muss einer verfügbaren Teilauszahlung entsprechen.", "validation.payout.count_reached": "Die maximale Anzahl an Auszahlungen für dieses Guthaben (:count) wurde erreicht.", "validation.postcode": "Es scheint, dass der :attribute falsch ist.", "validation.present": "Das Feld :attribute muss vorhanden sein.", diff --git a/resources/lang/en.json b/resources/lang/en.json index 1f6f4909b..fabbccf90 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -724,6 +724,10 @@ "passwords.sent": "We emailed a recovery link", "passwords.token": "This password recovery token is invalid.", "passwords.user": "This email address is not known to us", + "person_bsn_api.errors.connection_error": "Person social security number service connection error.", + "person_bsn_api.errors.not_filled_required_criteria": "Not all required criteria filled with prefill", + "person_bsn_api.errors.not_found": "Not found", + "person_bsn_api.errors.taken_by_partner": "Is taken by partner", "person_bsn_api.person_fields.address": "Place of residence", "person_bsn_api.person_fields.age": "Age", "person_bsn_api.person_fields.birth_date": "Date of birth", @@ -794,6 +798,12 @@ "policies.reservations.extra_payment_time_expired": "Payment time expired.", "policies.reservations.not_waiting": "Payment for reservation does not wait.", "policies.reservations.timeout_extra_payment": "It is not possible to cancel your reservation at this time. Please try at :time.", + "prevalidation_requests.reasons.connection_error": "Connection error", + "prevalidation_requests.reasons.empty_prevalidations": "Empty prevalidations", + "prevalidation_requests.reasons.invalid_records": "Invalid records", + "prevalidation_requests.reasons.not_filled_required_criteria": "Not meeting the required criteria", + "prevalidation_requests.reasons.not_found": "Not found", + "prevalidation_requests.reasons.taken_by_partner": "Tasks by partner", "prices.discount": "Discount::amount", "prices.discount_fixed": "Discount €", "prices.discount_percentage": "Discount %", @@ -1072,6 +1082,8 @@ "validation.file": ":attribute must be a file", "validation.filled": ":attribute field must contain a value.", "validation.fund_request.extra_records": "Additional records have been submitted that are not allowed.", + "validation.fund_request.group_required": "Fill in at least one of the records", + "validation.fund_request.invalid_prefill_value": "The selected :attribute is not permitted for this funding application.", "validation.fund_request.invalid_record": "The selected :attribute is not allowed for this fund application.", "validation.fund_request.required_record": "The :attribute field is required for the fund application.", "validation.fund_request_request_eligible_field_incomplete": "Agree to the terms and conditions.", @@ -1125,6 +1137,7 @@ "validation.organization_fund.wrong_categories": "Wrong categories.", "validation.payout.amount_between": "The payout amount must be between :min and :max.", "validation.payout.amount_exact": "The payout amount must be exactly :amount.", + "validation.payout.amount_partial": "The payout amount must correspond to an available partial payout.", "validation.payout.count_reached": "The maximum number of payouts for this credit (:count) has been reached.", "validation.postcode": "It seems that the :attribute is incorrect.", "validation.present": "The :attribute field must be present.", diff --git a/resources/lang/fr.json b/resources/lang/fr.json index 1d42ab258..f4b9e0a57 100644 --- a/resources/lang/fr.json +++ b/resources/lang/fr.json @@ -686,6 +686,10 @@ "passwords.sent": "Nous avons envoyé un lien de récupération", "passwords.token": "Ce jeton de récupération de mot de passe n'est pas valide.", "passwords.user": "Cette adresse e-mail n'est pas connue de nous", + "person_bsn_api.errors.connection_error": "Erreur de connexion au service Person bsn.", + "person_bsn_api.errors.not_filled_required_criteria": "Tous les critères requis ne sont pas remplis avec le préremplissage.", + "person_bsn_api.errors.not_found": "Introuvable", + "person_bsn_api.errors.taken_by_partner": "Is taken by partner", "person_bsn_api.person_fields.address": "Lieu de séjour", "person_bsn_api.person_fields.age": "Âge", "person_bsn_api.person_fields.birth_date": "Date de naissance", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Le délai de paiement a expiré.", "policies.reservations.not_waiting": "Le paiement de la réservation n'attend pas.", "policies.reservations.timeout_extra_payment": "Il n'est actuellement pas possible d'annuler votre réservation. Veuillez essayer de :time.", + "prevalidation_requests.reasons.connection_error": "Erreur de connexion", + "prevalidation_requests.reasons.empty_prevalidations": "Prévalidations vides", + "prevalidation_requests.reasons.invalid_records": "Enregistrements invalides", + "prevalidation_requests.reasons.not_filled_required_criteria": "Critères requis non remplis", + "prevalidation_requests.reasons.not_found": "Introuvable", + "prevalidation_requests.reasons.taken_by_partner": "Tâches effectuées par le partenaire", "prices.discount": "Remise : :amount", "prices.discount_fixed": "Remise €", "prices.discount_percentage": "Remise %", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute doit être un fichier", "validation.filled": "Le champ :attribute doit contenir une valeur.", "validation.fund_request.extra_records": "Des enregistrements supplémentaires ont été soumis alors qu'ils ne sont pas autorisés.", + "validation.fund_request.group_required": "Remplissez au moins un des champs.", + "validation.fund_request.invalid_prefill_value": "Le :attribute sélectionné n'est pas autorisé pour cette demande de financement.", "validation.fund_request.invalid_record": "Le :attribute sélectionné n'est pas autorisé pour cette demande de fonds.", "validation.fund_request.required_record": "Le champ :attribute est obligatoire pour la demande de fonds.", "validation.fund_request_request_eligible_field_incomplete": "Accepter les termes et conditions.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Mauvaises catégories.", "validation.payout.amount_between": "Le montant du paiement doit être compris entre :min et :max.", "validation.payout.amount_exact": "Le montant du paiement doit être exactement égal à :amount.", + "validation.payout.amount_partial": "Le montant du paiement doit correspondre à un paiement partiel disponible.", "validation.payout.count_reached": "Le nombre maximal de paiements pour ce crédit (:count) a été atteint.", "validation.postcode": "Il semble que le :attribute soit incorrect.", "validation.present": "Le champ :attribute doit être présent.", diff --git a/resources/lang/nl/validation.php b/resources/lang/nl/validation.php index c4db31fc5..62a912ebf 100644 --- a/resources/lang/nl/validation.php +++ b/resources/lang/nl/validation.php @@ -188,6 +188,7 @@ 'payout' => [ 'amount_between' => 'Het uitbetalingsbedrag moet tussen :min en :max liggen.', 'amount_exact' => 'Het uitbetalingsbedrag moet precies :amount zijn.', + 'amount_partial' => 'Het uitbetalingsbedrag moet overeenkomen met een beschikbare deeluitbetaling.', 'count_reached' => 'Het maximale aantal uitbetalingen voor dit tegoed (:count) is bereikt.', ], 'voucher' => [ diff --git a/resources/lang/pl.json b/resources/lang/pl.json index 267037f67..8fa23bc24 100644 --- a/resources/lang/pl.json +++ b/resources/lang/pl.json @@ -686,6 +686,10 @@ "passwords.sent": "Link do odzyskiwania został wysłany pocztą elektroniczną", "passwords.token": "Ten token odzyskiwania hasła jest nieprawidłowy.", "passwords.user": "Ten adres e-mail nie jest nam znany", + "person_bsn_api.errors.connection_error": "Błąd połączenia z usługą Person bsn.", + "person_bsn_api.errors.not_filled_required_criteria": "Nie wszystkie wymagane kryteria zostały wypełnione automatycznie.", + "person_bsn_api.errors.not_found": "Nie znaleziono", + "person_bsn_api.errors.taken_by_partner": "Zostało przejęte przez partnera", "person_bsn_api.person_fields.address": "Miejsce pobytu", "person_bsn_api.person_fields.age": "Wiek", "person_bsn_api.person_fields.birth_date": "Data urodzenia", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Czas płatności upłynął.", "policies.reservations.not_waiting": "Płatność za rezerwację nie czeka.", "policies.reservations.timeout_extra_payment": "Anulowanie rezerwacji nie jest obecnie możliwe. Spróbuj :time.", + "prevalidation_requests.reasons.connection_error": "Błąd połączenia", + "prevalidation_requests.reasons.empty_prevalidations": "Puste wstępne walidacje", + "prevalidation_requests.reasons.invalid_records": "Nieprawidłowe rekordy", + "prevalidation_requests.reasons.not_filled_required_criteria": "Nie spełnia wymaganych kryteriów", + "prevalidation_requests.reasons.not_found": "Nie znaleziono", + "prevalidation_requests.reasons.taken_by_partner": "Zadania wykonywane przez partnera", "prices.discount": "Rabat: :amount", "prices.discount_fixed": "Rabat €", "prices.discount_percentage": "Rabat %", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute musi być plikiem", "validation.filled": "Pole :attribute musi zawierać wartość.", "validation.fund_request.extra_records": "Przesłano dodatkowe rekordy, które nie są dozwolone.", + "validation.fund_request.group_required": "Wypełnij co najmniej jeden z rekordów", + "validation.fund_request.invalid_prefill_value": "Wybrany :attribute nie jest dozwolony w przypadku tego wniosku o fundusz.", "validation.fund_request.invalid_record": "Wybrany :attribute nie jest dozwolony dla tej aplikacji funduszu.", "validation.fund_request.required_record": "Pole :attribute jest obowiązkowe dla aplikacji funduszu.", "validation.fund_request_request_eligible_field_incomplete": "Zgoda na warunki umowy.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Nieprawidłowe kategorie.", "validation.payout.amount_between": "Kwota wypłaty musi mieścić się w przedziale od :min do :max.", "validation.payout.amount_exact": "Kwota wypłaty musi wynosić dokładnie :amount.", + "validation.payout.amount_partial": "Kwota wypłaty musi odpowiadać dostępnej częściowej wypłacie.", "validation.payout.count_reached": "Osiągnięto maksymalną liczbę wypłat dla tego salda (:count).", "validation.postcode": "Wygląda na to, że :attribute jest nieprawidłowy.", "validation.present": "Pole :attribute musi być obecne.", diff --git a/resources/lang/ru.json b/resources/lang/ru.json index 7dda4a8b9..b69d30cf2 100644 --- a/resources/lang/ru.json +++ b/resources/lang/ru.json @@ -686,6 +686,10 @@ "passwords.sent": "Мы отправили ссылку на восстановление по электронной почте", "passwords.token": "Этот маркер восстановления пароля недействителен.", "passwords.user": "Этот адрес электронной почты нам неизвестен", + "person_bsn_api.errors.connection_error": "Ошибка подключения к службе персонального номера социального страхования.", + "person_bsn_api.errors.not_filled_required_criteria": "Не все обязательные критерии заполнены с помощью предварительного заполнения", + "person_bsn_api.errors.not_found": "Не найдено", + "person_bsn_api.errors.taken_by_partner": "Занято партнером", "person_bsn_api.person_fields.address": "Место проживания", "person_bsn_api.person_fields.age": "Возраст", "person_bsn_api.person_fields.birth_date": "Дата рождения", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Время оплаты истекло.", "policies.reservations.not_waiting": "Оплата бронирования не заставит себя ждать.", "policies.reservations.timeout_extra_payment": "В настоящее время отмена бронирования невозможна. Пожалуйста, попробуйте :time.", + "prevalidation_requests.reasons.connection_error": "Ошибка соединения", + "prevalidation_requests.reasons.empty_prevalidations": "Пустые предварительные проверки", + "prevalidation_requests.reasons.invalid_records": "Недействительные записи", + "prevalidation_requests.reasons.not_filled_required_criteria": "Не соответствует требуемым критериям", + "prevalidation_requests.reasons.not_found": "Не найдено", + "prevalidation_requests.reasons.taken_by_partner": "Задачи партнера", "prices.discount": "Скидка: :amount", "prices.discount_fixed": "Скидка €", "prices.discount_percentage": "Скидка %", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute должен быть файлом", "validation.filled": "Поле :attribute должно содержать значение.", "validation.fund_request.extra_records": "Были представлены дополнительные записи, которые не разрешены.", + "validation.fund_request.group_required": "Заполните хотя бы одну из записей", + "validation.fund_request.invalid_prefill_value": "Выбранный :attribute не допускается для данного запроса на финансирование.", "validation.fund_request.invalid_record": "Выбранный :attribute не разрешен для данного приложения фонда.", "validation.fund_request.required_record": "Поле :attribute является обязательным для приложения фонда.", "validation.fund_request_request_eligible_field_incomplete": "Согласитесь с правилами и условиями.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Неправильные категории.", "validation.payout.amount_between": "Сумма выплаты должна быть в диапазоне от :min до :max.", "validation.payout.amount_exact": "Сумма выплаты должна быть ровно :amount.", + "validation.payout.amount_partial": "Сумма выплаты должна соответствовать доступной части выплаты.", "validation.payout.count_reached": "Достигнут максимальный лимит выплат для этого баланса (:count).", "validation.postcode": "Похоже, что :attribute указан неверно.", "validation.present": "Поле :attribute должно присутствовать.", diff --git a/resources/lang/tr.json b/resources/lang/tr.json index 93bfa2d75..1083d2ab6 100644 --- a/resources/lang/tr.json +++ b/resources/lang/tr.json @@ -686,6 +686,10 @@ "passwords.sent": "Bir kurtarma bağlantısını e-posta ile gönderdik", "passwords.token": "Bu parola kurtarma belirteci geçersiz.", "passwords.user": "Bu e-posta adresi tarafımızca bilinmemektedir", + "person_bsn_api.errors.connection_error": "Kişi bsn hizmet bağlantı hatası.", + "person_bsn_api.errors.not_filled_required_criteria": "Önceden doldurulmuş tüm gerekli kriterler doldurulmadı", + "person_bsn_api.errors.not_found": "Bulunamadı", + "person_bsn_api.errors.taken_by_partner": "Partner tarafından alınmıştır", "person_bsn_api.person_fields.address": "Konaklama yeri", "person_bsn_api.person_fields.age": "Yaş", "person_bsn_api.person_fields.birth_date": "Doğum tarihi", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Ödeme süresi doldu.", "policies.reservations.not_waiting": "Rezervasyon için ödeme beklemez.", "policies.reservations.timeout_extra_payment": "Şu anda rezervasyonunuzu iptal etmeniz mümkün değildir. Lütfen :time adresini deneyin.", + "prevalidation_requests.reasons.connection_error": "Bağlantı hatası", + "prevalidation_requests.reasons.empty_prevalidations": "Boş ön doğrulamalar", + "prevalidation_requests.reasons.invalid_records": "Geçersiz kayıtlar", + "prevalidation_requests.reasons.not_filled_required_criteria": "Gerekli kriterler karşılanmadı", + "prevalidation_requests.reasons.not_found": "Bulunamadı", + "prevalidation_requests.reasons.taken_by_partner": "Ortak tarafından üstlenilen görevler", "prices.discount": "İndirim: :amount", "prices.discount_fixed": "İndirim €", "prices.discount_percentage": "İndirim %", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute bir dosya olmalıdır", "validation.filled": ":attribute alanı bir değer içermelidir.", "validation.fund_request.extra_records": "İzin verilmeyen ek kayıtlar sunulmuştur.", + "validation.fund_request.group_required": "En az bir kaydı doldurun", + "validation.fund_request.invalid_prefill_value": "Seçilen :attribute bu fon başvurusu için izin verilmez.", "validation.fund_request.invalid_record": "Seçilen :attribute bu fon başvurusu için izin verilmiyor.", "validation.fund_request.required_record": ":attribute alanı fon başvurusu için zorunludur.", "validation.fund_request_request_eligible_field_incomplete": "Hüküm ve koşulları kabul edin.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Yanlış kategoriler.", "validation.payout.amount_between": "Ödeme tutarı :min ile :max arasında olmalıdır.", "validation.payout.amount_exact": "Ödeme tutarı tam olarak :amount olmalıdır.", + "validation.payout.amount_partial": "Ödeme tutarı, mevcut kısmi ödeme ile aynı olmalıdır.", "validation.payout.count_reached": "Bu bakiye için maksimum ödeme sayısı (:count) ulaşılmıştır.", "validation.postcode": "Görünüşe göre :attribute yanlış.", "validation.present": ":attribute alanı mevcut olmalıdır.", diff --git a/resources/lang/uk.json b/resources/lang/uk.json index 1535e8d6f..447e88967 100644 --- a/resources/lang/uk.json +++ b/resources/lang/uk.json @@ -686,6 +686,10 @@ "passwords.sent": "Ми надіслали посилання для відновлення", "passwords.token": "Цей токен для відновлення пароля недійсний.", "passwords.user": "Ця електронна адреса нам не відома", + "person_bsn_api.errors.connection_error": "Помилка підключення до служби Person bsn.", + "person_bsn_api.errors.not_filled_required_criteria": "Не всі необхідні критерії заповнені заздалегідь", + "person_bsn_api.errors.not_found": "Не знайдено", + "person_bsn_api.errors.taken_by_partner": "Зайнято партнером", "person_bsn_api.person_fields.address": "Місце проживання", "person_bsn_api.person_fields.age": "Вік", "person_bsn_api.person_fields.birth_date": "Дата народження", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Час оплати закінчився.", "policies.reservations.not_waiting": "Оплата за бронювання не чекає.", "policies.reservations.timeout_extra_payment": "Наразі скасувати бронювання неможливо. Будь ласка, спробуйте зв'язатися з :time.", + "prevalidation_requests.reasons.connection_error": "Помилка з'єднання", + "prevalidation_requests.reasons.empty_prevalidations": "Порожні попередні перевірки", + "prevalidation_requests.reasons.invalid_records": "Недійсні записи", + "prevalidation_requests.reasons.not_filled_required_criteria": "Не відповідає необхідним критеріям", + "prevalidation_requests.reasons.not_found": "Не знайдено", + "prevalidation_requests.reasons.taken_by_partner": "Виконано партнером", "prices.discount": "Знижка: :amount.", "prices.discount_fixed": "Знижка €", "prices.discount_percentage": "Знижка в % від вартості", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute має бути файлом", "validation.filled": "Поле :attribute має містити значення.", "validation.fund_request.extra_records": "Були подані додаткові записи, які не допускаються.", + "validation.fund_request.group_required": "Заповніть хоча б один із записів", + "validation.fund_request.invalid_prefill_value": "Вибраний :attribute не дозволений для цього запиту на фінансування.", "validation.fund_request.invalid_record": "Обраний :attribute не може подавати заявку на цей фонд.", "validation.fund_request.required_record": "Поле :attribute є обов'язковим для заповнення при подачі заявки на фінансування.", "validation.fund_request_request_eligible_field_incomplete": "Погодьтеся з умовами та положеннями.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Не ті категорії.", "validation.payout.amount_between": "Сума виплати повинна бути в межах від :min до :max.", "validation.payout.amount_exact": "Сума виплати повинна дорівнювати точно :amount.", + "validation.payout.amount_partial": "Сума виплати повинна відповідати доступній частині виплати.", "validation.payout.count_reached": "Максимальна кількість виплат за цим кредитом (:count) досягнута.", "validation.postcode": "Здається, :attribute невірно вказано.", "validation.present": "Поле :attribute повинно бути присутнім.", diff --git a/storage/translations-caches/cache.json b/storage/translations-caches/cache.json index aeef96ea8..41848ac3a 100644 --- a/storage/translations-caches/cache.json +++ b/storage/translations-caches/cache.json @@ -686,6 +686,10 @@ "passwords.sent": "We hebben een herstel link per e-mail gestuurd", "passwords.token": "Dit wachtwoordhersteltoken is ongeldig.", "passwords.user": "Dit e-mailadres is niet bij ons bekend", + "person_bsn_api.errors.connection_error": "Person bsn service connection error.", + "person_bsn_api.errors.not_filled_required_criteria": "Not all required criteria filled with prefill", + "person_bsn_api.errors.not_found": "Not found", + "person_bsn_api.errors.taken_by_partner": "Is taken by partner", "person_bsn_api.person_fields.address": "Verblijfsplaats", "person_bsn_api.person_fields.age": "Leeftijd", "person_bsn_api.person_fields.birth_date": "Geboortedatum", @@ -753,6 +757,12 @@ "policies.reservations.extra_payment_time_expired": "Betaaltijd is verstreken.", "policies.reservations.not_waiting": "Betaling voor reservering wacht niet.", "policies.reservations.timeout_extra_payment": "Het is op dit moment niet mogelijk om uw reservering te annuleren. Probeer het om :time.", + "prevalidation_requests.reasons.connection_error": "Connection error", + "prevalidation_requests.reasons.empty_prevalidations": "Empty prevalidations", + "prevalidation_requests.reasons.invalid_records": "Invalid records", + "prevalidation_requests.reasons.not_filled_required_criteria": "Not filled required criteria", + "prevalidation_requests.reasons.not_found": "Not found", + "prevalidation_requests.reasons.taken_by_partner": "Taken by partner", "prices.discount": "Korting: :amount", "prices.discount_fixed": "Korting €", "prices.discount_percentage": "Korting %", @@ -1031,6 +1041,8 @@ "validation.file": ":attribute moet een bestand zijn", "validation.filled": ":attribute veld moet een waarde bevatten.", "validation.fund_request.extra_records": "Er zijn extra records ingediend die niet zijn toegestaan.", + "validation.fund_request.group_required": "Fill at least one of records", + "validation.fund_request.invalid_prefill_value": "Het geselecteerde :attribute is niet toegestaan voor deze fondaanvraag.", "validation.fund_request.invalid_record": "Het geselecteerde :attribute is niet toegestaan voor deze fondaanvraag.", "validation.fund_request.required_record": "Het veld :attribute is verplicht voor de fondaanvraag.", "validation.fund_request_request_eligible_field_incomplete": "Ga akkoord met de voorwaarden.", @@ -1084,6 +1096,7 @@ "validation.organization_fund.wrong_categories": "Verkeerde categorieën.", "validation.payout.amount_between": "Het uitbetalingsbedrag moet tussen :min en :max liggen.", "validation.payout.amount_exact": "Het uitbetalingsbedrag moet precies :amount zijn.", + "validation.payout.amount_partial": "Het uitbetalingsbedrag moet overeenkomen met een beschikbare deeluitbetaling.", "validation.payout.count_reached": "Het maximale aantal uitbetalingen voor dit tegoed (:count) is bereikt.", "validation.postcode": "Het lijkt erop dat de :attribute niet klopt.", "validation.present": "Het :attribute veld moet aanwezig zijn.", diff --git a/tests/Browser/RequesterVoucherPayoutTest.php b/tests/Browser/RequesterVoucherPayoutTest.php index 41d74e18c..f7f9ca292 100644 --- a/tests/Browser/RequesterVoucherPayoutTest.php +++ b/tests/Browser/RequesterVoucherPayoutTest.php @@ -2,8 +2,8 @@ namespace Tests\Browser; -use App\Models\Implementation; use App\Models\FundPayoutFormula; +use App\Models\Implementation; use App\Models\VoucherTransaction; use Illuminate\Foundation\Testing\WithFaker; use Laravel\Dusk\Browser; @@ -91,6 +91,183 @@ public function testRequesterVoucherPayoutFlow(): void }); } + /** + * @throws Throwable + */ + public function testRequesterPartialPayoutUsesRegularAmountInputWhenPartialIsDisabled(): void + { + $implementation = Implementation::byKey('nijmegen'); + $organization = $implementation->organization; + $organizationState = $organization->only(['fund_request_resolve_policy', 'allow_profiles']); + + $organization->forceFill(['allow_profiles' => true])->save(); + $fund = $this->makePayoutEnabledFund($organization, $implementation); + + $identity = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + + $result = $this->makePayoutVoucherViaApplication($identity, $fund); + $voucher = $result['voucher']; + + $this->rollbackModels([ + [$organization, $organizationState], + ], function () use ($implementation, $identity, $voucher) { + $this->browse(function (Browser $browser) use ($implementation, $identity, $voucher) { + $browser->visit($implementation->urlWebshop()); + + $this->loginIdentity($browser, $identity); + $this->assertIdentityAuthenticatedOnWebshop($browser, $identity); + + $this->goToIdentityVouchers($browser); + $browser->waitFor("@listVouchersRow$voucher->id"); + $browser->click("@listVouchersRow$voucher->id"); + + $browser->waitFor('@voucherTitle'); + $browser->waitFor('@openVoucherPayoutModal'); + $browser->press('@openVoucherPayoutModal'); + + $browser->waitFor('@voucherPayoutForm'); + $browser->waitFor('@voucherPayoutAmount'); + $browser->assertAttribute('@voucherPayoutAmount', 'type', 'number'); + $browser->typeSlowly('@voucherPayoutAmount', '50.00', 20); + $browser->press('@voucherPayoutAcceptRules'); + $browser->press('@voucherPayoutSubmit'); + $browser->waitFor('@voucherPayoutSuccess'); + $browser->press('@voucherPayoutSuccessClose'); + + $this->logout($browser); + }); + }, function () use ($fund) { + $fund && $this->deleteFund($fund); + }); + } + + /** + * @throws Throwable + */ + public function testRequesterPartialPayoutShowsWarningWhenNoAmountsAvailable(): void + { + $implementation = Implementation::byKey('nijmegen'); + $organization = $implementation->organization; + $organizationState = $organization->only(['fund_request_resolve_policy', 'allow_profiles']); + + $organization->forceFill(['allow_profiles' => true])->save(); + $fund = $this->makePayoutEnabledFund($organization, $implementation, [ + 'allow_voucher_payouts_partial' => true, + ]); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 50, + 'record_type_key' => $recordKey, + ]); + + $identity = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + + $result = $this->makePayoutVoucherViaApplication($identity, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $voucher->forceFill(['amount' => 200])->save(); + $this->createTrustedRecord($identity, $fund, $fundRequest, $recordKey, 0); + + $this->rollbackModels([ + [$organization, $organizationState], + ], function () use ($implementation, $identity, $voucher) { + $this->browse(function (Browser $browser) use ($implementation, $identity, $voucher) { + $browser->visit($implementation->urlWebshop()); + + $this->loginIdentity($browser, $identity); + $this->assertIdentityAuthenticatedOnWebshop($browser, $identity); + + $this->goToIdentityVouchers($browser); + $browser->waitFor("@listVouchersRow$voucher->id"); + $browser->click("@listVouchersRow$voucher->id"); + + $browser->waitFor('@voucherTitle'); + $browser->waitFor('@openVoucherPayoutModal'); + $browser->press('@openVoucherPayoutModal'); + + $browser->waitFor('.block-warning'); + $browser->assertMissing('@voucherPayoutAmount'); + $browser->assertDisabled('@voucherPayoutSubmit'); + $browser->click('.modal-close'); + + $this->logout($browser); + }); + }, function () use ($fund) { + $fund && $this->deleteFund($fund); + }); + } + + /** + * @throws Throwable + */ + public function testRequesterPartialPayoutAllowsSelectingAmount(): void + { + $implementation = Implementation::byKey('nijmegen'); + $organization = $implementation->organization; + $organizationState = $organization->only(['fund_request_resolve_policy', 'allow_profiles']); + + $organization->forceFill(['allow_profiles' => true])->save(); + $fund = $this->makePayoutEnabledFund($organization, $implementation, [ + 'allow_voucher_payouts_partial' => true, + ]); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 50, + 'record_type_key' => $recordKey, + ]); + + $identity = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + + $result = $this->makePayoutVoucherViaApplication($identity, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $voucher->forceFill(['amount' => 200])->save(); + $this->createTrustedRecord($identity, $fund, $fundRequest, $recordKey, 3); + + $this->rollbackModels([ + [$organization, $organizationState], + ], function () use ($implementation, $identity, $voucher) { + $this->browse(function (Browser $browser) use ($implementation, $identity, $voucher) { + $browser->visit($implementation->urlWebshop()); + + $this->loginIdentity($browser, $identity); + $this->assertIdentityAuthenticatedOnWebshop($browser, $identity); + + $this->goToIdentityVouchers($browser); + $browser->waitFor("@listVouchersRow$voucher->id"); + $browser->click("@listVouchersRow$voucher->id"); + + $browser->waitFor('@voucherTitle'); + $browser->waitFor('@openVoucherPayoutModal'); + $browser->press('@openVoucherPayoutModal'); + + $options = ['€ 50,-', '€ 100,-', '€ 150,-']; + + $this->assertSelectControlOptionsExists($browser, '@voucherPayoutAmount', $options); + $this->changeSelectControl($browser, '@voucherPayoutAmount', text: '€ 100,-'); + + $browser->press('@voucherPayoutAcceptRules'); + $browser->press('@voucherPayoutSubmit'); + $browser->waitFor('@voucherPayoutSuccess'); + $browser->press('@voucherPayoutSuccessClose'); + + $this->logout($browser); + }); + }, function () use ($fund) { + $fund && $this->deleteFund($fund); + }); + } + /** * @throws Throwable */ @@ -362,4 +539,5 @@ public function testProductPayoutButtonVisibleForInformationalProduct(): void $fund && $this->deleteFund($fund); }); } + } diff --git a/tests/Browser/Traits/HasFrontendActions.php b/tests/Browser/Traits/HasFrontendActions.php index 850232c30..65939ceec 100644 --- a/tests/Browser/Traits/HasFrontendActions.php +++ b/tests/Browser/Traits/HasFrontendActions.php @@ -136,8 +136,8 @@ protected function clearField(Browser $browser, string $selector): void * @param int $count * @param bool $waitForItems * @param string|null $filePath - * @return void * @throws TimeoutException + * @return void */ protected function attachFilesToFileUploader( Browser $browser, @@ -303,6 +303,28 @@ protected function changeSelectControl(Browser $browser, string $selector, strin $this->findOptionElement($browser, $selector, text: $text, index: $index)->click(); } + /** + * @param Browser $browser + * @param string $selector + * @param string[] $options + * @throws ElementClickInterceptedException + * @throws NoSuchElementException + * @throws TimeoutException + * @return void + */ + protected function assertSelectControlOptionsExists(Browser $browser, string $selector, array $options): void + { + $browser->waitFor($selector); + $browser->assertAttribute($selector, 'role', 'combobox'); + $browser->click("$selector .select-control-search"); + + foreach ($options as $option) { + $this->findOptionElement($browser, $selector, text: $option); + } + + $browser->click("$selector .select-control-search"); + } + /** * @param Browser $browser * @param int $count diff --git a/tests/Feature/RequesterVoucherPayoutTest.php b/tests/Feature/RequesterVoucherPayoutTest.php index fc4b478dd..b48a967a6 100644 --- a/tests/Feature/RequesterVoucherPayoutTest.php +++ b/tests/Feature/RequesterVoucherPayoutTest.php @@ -6,11 +6,6 @@ use App\Models\FundPayoutFormula; use App\Models\FundRequest; use App\Models\Identity; -use App\Models\Organization; -use App\Models\Prevalidation; -use App\Models\Record; -use App\Models\RecordType; -use App\Models\RecordValidation; use App\Models\Voucher; use App\Models\VoucherTransaction; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -329,6 +324,231 @@ public function testRequesterPayoutMultiplyFormulaValidation(): void ); } + /** + * @throws Throwable + * @return void + */ + public function testRequesterPayoutPartialAmountsAreCalculated(): void + { + $requester = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + $sponsorOrganization = $this->makeTestOrganization($this->makeIdentity()); + $sponsorOrganization->forceFill(['allow_profiles' => true])->save(); + + $fund = $this->makePayoutEnabledFund($sponsorOrganization); + + $fund->fund_config->forceFill([ + 'allow_voucher_payouts_partial' => true, + ])->save(); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($fund->organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 50, + 'record_type_key' => $recordKey, + ]); + + $result = $this->makePayoutVoucherViaApplication($requester, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $this->createTrustedRecord($requester, $fund, $fundRequest, $recordKey, 3); + + $voucherRes = $this->getJson("/api/v1/platform/vouchers/$voucher->number", $this->makeApiHeaders($requester)); + $voucherRes->assertSuccessful(); + $voucherRes->assertJsonPath('data.voucher_payout_partial_amounts', [ + '50.00', + '100.00', + '150.00', + ]); + + $this->apiMakePayout([ + 'voucher_id' => $voucher->id, + 'amount' => '100.00', + 'fund_request_id' => $fundRequest->id, + ], $requester); + + $voucherRes = $this->getJson("/api/v1/platform/vouchers/$voucher->number", $this->makeApiHeaders($requester)); + $voucherRes->assertSuccessful(); + $voucherRes->assertJsonPath('data.voucher_payout_partial_amounts', [ + '50.00', + ]); + + $this->apiMakePayoutRequest([ + 'voucher_id' => $voucher->id, + 'amount' => '150.00', + 'fund_request_id' => $fundRequest->id, + ], $requester)->assertJsonValidationErrorFor('amount'); + } + + /** + * @throws Throwable + * @return void + */ + public function testRequesterPayoutPartialAmountCapApplied(): void + { + $requester = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + $sponsorOrganization = $this->makeTestOrganization($this->makeIdentity()); + $sponsorOrganization->forceFill(['allow_profiles' => true])->save(); + + $fund = $this->makePayoutEnabledFund($sponsorOrganization); + + $fund->fund_config->forceFill([ + 'allow_voucher_payouts_partial' => true, + ])->save(); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($fund->organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 50, + 'max_amount' => 200, + 'record_type_key' => $recordKey, + ]); + + $result = $this->makePayoutVoucherViaApplication($requester, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $this->createTrustedRecord($requester, $fund, $fundRequest, $recordKey, 10); + + $voucherRes = $this->getJson("/api/v1/platform/vouchers/$voucher->number", $this->makeApiHeaders($requester)); + $voucherRes->assertSuccessful(); + $voucherRes->assertJsonPath('data.voucher_payout_partial_amounts', [ + '50.00', + '100.00', + '150.00', + '200.00', + ]); + + $this->apiMakePayoutRequest([ + 'voucher_id' => $voucher->id, + 'amount' => '250.00', + 'fund_request_id' => $fundRequest->id, + ], $requester)->assertJsonValidationErrorFor('amount'); + } + + /** + * @throws Throwable + * @return void + */ + public function testRequesterPayoutPartialAmountDefaultCapApplied(): void + { + $requester = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + $sponsorOrganization = $this->makeTestOrganization($this->makeIdentity()); + $sponsorOrganization->forceFill(['allow_profiles' => true])->save(); + + $fund = $this->makePayoutEnabledFund($sponsorOrganization); + + $fund->fund_config->forceFill([ + 'allow_voucher_payouts_partial' => true, + ])->save(); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($fund->organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 1000, + 'record_type_key' => $recordKey, + ]); + + $result = $this->makePayoutVoucherViaApplication($requester, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $this->createTrustedRecord($requester, $fund, $fundRequest, $recordKey, 100); + $voucher->forceFill(['amount' => 10000])->save(); + + $voucherRes = $this->getJson("/api/v1/platform/vouchers/$voucher->number", $this->makeApiHeaders($requester)); + $voucherRes->assertSuccessful(); + $voucherRes->assertJsonCount(5, 'data.voucher_payout_partial_amounts'); + $voucherRes->assertJsonPath('data.voucher_payout_partial_amounts.4', '5000.00'); + } + + /** + * @throws Throwable + * @return void + */ + public function testRequesterPayoutPartialAmountZeroMaxReturnsEmpty(): void + { + $requester = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + $sponsorOrganization = $this->makeTestOrganization($this->makeIdentity()); + $sponsorOrganization->forceFill(['allow_profiles' => true])->save(); + + $fund = $this->makePayoutEnabledFund($sponsorOrganization, fundConfigsData: [ + 'allow_voucher_payouts_partial' => true, + ]); + + $fund->fund_config->forceFill([ + 'allow_voucher_payouts_partial' => true, + ])->save(); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($fund->organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 50, + 'max_amount' => 0, + 'record_type_key' => $recordKey, + ]); + + $result = $this->makePayoutVoucherViaApplication($requester, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $this->createTrustedRecord($requester, $fund, $fundRequest, $recordKey, 3); + + $voucherRes = $this->getJson("/api/v1/platform/vouchers/$voucher->number", $this->makeApiHeaders($requester)); + $voucherRes->assertSuccessful(); + $voucherRes->assertJsonPath('data.voucher_payout_partial_amounts', []); + } + + /** + * @throws Throwable + * @return void + */ + public function testRequesterPayoutPartialAmountCappedByVoucherBalance(): void + { + $requester = $this->makeIdentity($this->makeUniqueEmail(), bsn: $this->randomFakeBsn()); + $sponsorOrganization = $this->makeTestOrganization($this->makeIdentity()); + $sponsorOrganization->forceFill(['allow_profiles' => true])->save(); + + $fund = $this->makePayoutEnabledFund($sponsorOrganization, fundConfigsData: [ + 'allow_voucher_payouts_partial' => true, + ]); + + $fund->fund_config->forceFill([ + 'allow_voucher_payouts_partial' => true, + ])->save(); + + $recordKey = 'payout_partial_' . token_generator()->generate(6); + $this->ensureNumberRecordType($fund->organization, $recordKey); + + $fund->fund_payout_formulas()->create([ + 'type' => FundPayoutFormula::TYPE_MULTIPLY, + 'amount' => 50, + 'record_type_key' => $recordKey, + ]); + + $result = $this->makePayoutVoucherViaApplication($requester, $fund); + $voucher = $result['voucher']; + $fundRequest = $result['fund_request']; + + $this->createTrustedRecord($requester, $fund, $fundRequest, $recordKey, 3); + $voucher->forceFill(['amount' => 120])->save(); + + $voucherRes = $this->getJson("/api/v1/platform/vouchers/$voucher->number", $this->makeApiHeaders($requester)); + $voucherRes->assertSuccessful(); + $voucherRes->assertJsonPath('data.voucher_payout_partial_amounts', [ + '50.00', + '100.00', + ]); + } + /** * @throws Throwable * @return void @@ -701,87 +921,17 @@ private function calculateFormulaTotal(array $formulas, array $recordValues): fl } $value = isset($recordValues[$key]) ? (float) $recordValues[$key] : 0.0; - $total += (float) $formula['amount'] * $value; - } - } + $amount = (float) $formula['amount'] * $value; - return $total; - } + if (isset($formula['max_amount'])) { + $amount = min($amount, (float) $formula['max_amount']); + } - /** - * @param Organization $organization - * @param string $recordTypeKey - * @return RecordType - */ - private function ensureNumberRecordType(Organization $organization, string $recordTypeKey): RecordType - { - $recordType = RecordType::query() - ->where('organization_id', $organization->id) - ->where('key', $recordTypeKey) - ->first(); - - if ($recordType) { - if (!$recordType->criteria) { - $recordType->forceFill(['criteria' => true])->save(); + $total += $amount; } - - return $recordType; } - return RecordType::create([ - 'organization_id' => $organization->id, - 'criteria' => true, - 'type' => RecordType::TYPE_NUMBER, - 'key' => $recordTypeKey, - ]); + return $total; } - /** - * @param Identity $identity - * @param Fund $fund - * @param FundRequest $fundRequest - * @param string $recordTypeKey - * @param string|float $value - * @return void - */ - private function createTrustedRecord( - Identity $identity, - Fund $fund, - FundRequest $fundRequest, - string $recordTypeKey, - string|float $value, - ): void { - $recordType = $this->ensureNumberRecordType($fund->organization, $recordTypeKey); - - Record::where('identity_address', $identity->address) - ->where('record_type_id', $recordType->id) - ->forceDelete(); - - $record = Record::create([ - 'identity_address' => $identity->address, - 'record_type_id' => $recordType->id, - 'fund_request_id' => $fundRequest->id, - 'organization_id' => $fund->organization_id, - 'value' => (string) $value, - 'order' => 0, - ]); - - $prevalidation = Prevalidation::create([ - 'uid' => token_generator()->generate(32), - 'identity_address' => $identity->address, - 'fund_id' => $fund->id, - 'organization_id' => $fund->organization_id, - 'state' => Prevalidation::STATE_PENDING, - 'validated_at' => now(), - ]); - - RecordValidation::create([ - 'record_id' => $record->id, - 'state' => RecordValidation::STATE_APPROVED, - 'uuid' => token_generator()->generate(64), - 'identity_address' => $identity->address, - 'organization_id' => $fund->organization_id, - 'prevalidation_id' => $prevalidation->id, - ]); - } } diff --git a/tests/Feature/VoucherTest.php b/tests/Feature/VoucherTest.php index c8c745427..a3c93a370 100644 --- a/tests/Feature/VoucherTest.php +++ b/tests/Feature/VoucherTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Mail\Vouchers\SendProductVoucherMail; use App\Mail\Vouchers\SendVoucherMail; use App\Mail\Vouchers\ShareProductVoucherMail; use App\Models\Fund; @@ -995,7 +996,8 @@ protected function assertAbilityIdentitySendVoucherEmail(Voucher $voucher): void $response = $this->post($url, [], $headers); $response->assertSuccessful(); - $this->assertMailableSent($voucher->identity->email, SendVoucherMail::class, $startDate); + $mailable = $voucher->product ? SendProductVoucherMail::class : SendVoucherMail::class; + $this->assertMailableSent($voucher->identity->email, $mailable, $startDate); } } diff --git a/tests/Traits/MakesRequesterVoucherPayouts.php b/tests/Traits/MakesRequesterVoucherPayouts.php index 1a9fdc79c..0af93a142 100644 --- a/tests/Traits/MakesRequesterVoucherPayouts.php +++ b/tests/Traits/MakesRequesterVoucherPayouts.php @@ -7,7 +7,10 @@ use App\Models\Identity; use App\Models\Implementation; use App\Models\Organization; +use App\Models\Prevalidation; +use App\Models\Record; use App\Models\RecordType; +use App\Models\RecordValidation; use App\Models\Voucher; trait MakesRequesterVoucherPayouts @@ -122,4 +125,81 @@ private function getPayoutIbanRecordKeys(): array { return ['iban_requester_payout', 'iban_name_requester_payout']; } + + /** + * @param Organization $organization + * @param string $recordTypeKey + * @return RecordType + */ + protected function ensureNumberRecordType(Organization $organization, string $recordTypeKey): RecordType + { + $recordType = RecordType::query() + ->where('organization_id', $organization->id) + ->where('key', $recordTypeKey) + ->first(); + + if ($recordType) { + if (!$recordType->criteria) { + $recordType->forceFill(['criteria' => true])->save(); + } + + return $recordType; + } + + return RecordType::create([ + 'organization_id' => $organization->id, + 'criteria' => true, + 'type' => RecordType::TYPE_NUMBER, + 'key' => $recordTypeKey, + ]); + } + + /** + * @param Identity $identity + * @param Fund $fund + * @param FundRequest $fundRequest + * @param string $recordTypeKey + * @param string|float $value + * @return void + */ + protected function createTrustedRecord( + Identity $identity, + Fund $fund, + FundRequest $fundRequest, + string $recordTypeKey, + string|float $value, + ): void { + $recordType = $this->ensureNumberRecordType($fund->organization, $recordTypeKey); + + Record::where('identity_address', $identity->address) + ->where('record_type_id', $recordType->id) + ->forceDelete(); + + $record = Record::create([ + 'identity_address' => $identity->address, + 'record_type_id' => $recordType->id, + 'fund_request_id' => $fundRequest->id, + 'organization_id' => $fund->organization_id, + 'value' => (string) $value, + 'order' => 0, + ]); + + $prevalidation = Prevalidation::create([ + 'uid' => token_generator()->generate(32), + 'identity_address' => $identity->address, + 'fund_id' => $fund->id, + 'organization_id' => $fund->organization_id, + 'state' => Prevalidation::STATE_PENDING, + 'validated_at' => now(), + ]); + + RecordValidation::create([ + 'record_id' => $record->id, + 'state' => RecordValidation::STATE_APPROVED, + 'uuid' => token_generator()->generate(64), + 'identity_address' => $identity->address, + 'organization_id' => $fund->organization_id, + 'prevalidation_id' => $prevalidation->id, + ]); + } } diff --git a/tests/Unit/MailTests/VoucherSharedMailTest.php b/tests/Unit/MailTests/VoucherSharedMailTest.php new file mode 100644 index 000000000..458345b62 --- /dev/null +++ b/tests/Unit/MailTests/VoucherSharedMailTest.php @@ -0,0 +1,123 @@ +makeTestOrganization($this->makeIdentity()); + $fund = $this->makeTestFund($organization); + + $voucher = $fund->makeVoucher(); + + // trigger event to send email by sponsor with empty email + Event::dispatch(new VoucherSendToEmailBySponsorEvent($voucher, null)); + + // assert no email was sent + $emailQuery = EmailLog::where(function (Builder $builder) use ($startDate) { + $builder->where('created_at', '>=', $startDate); + $builder->where('mailable', SendVoucherBySponsorMail::class); + }); + + $this->assertFalse($emailQuery->exists()); + + // assert no email sent if we pass email but there are no identity with such email + $email = $this->makeUniqueEmail(); + Event::dispatch(new VoucherSendToEmailBySponsorEvent($voucher, $email)); + $this->assertMailableNotSent($email, SendVoucherBySponsorMail::class, $startDate); + + // assert email was sent to email as identity exists + $email = $this->makeIdentity($this->makeUniqueEmail())->email; + Event::dispatch(new VoucherSendToEmailBySponsorEvent($voucher, $email)); + $this->assertMailableSent($email, SendVoucherBySponsorMail::class, $startDate); + + // wait 1 sec to assert that passed email ignored if voucher is granted + sleep(1); + $startDate = now(); + + $identity = $this->makeIdentity($this->makeUniqueEmail()); + $voucher->assignToIdentity($identity); + + Event::dispatch(new VoucherSendToEmailBySponsorEvent($voucher, $email)); + + $this->assertMailableNotSent($email, SendVoucherBySponsorMail::class, $startDate); + $this->assertMailableSent($identity->email, SendVoucherBySponsorMail::class, $startDate); + } + + /** + * @return void + */ + public function testSponsorVoucherShareMailReplacesAmountAndExpirationDate(): void + { + $startDate = now(); + $organization = $this->makeTestOrganization($this->makeIdentity()); + $fund = $this->makeTestFund($organization, fundConfigsData: [ + 'show_qr_code' => true, + ]); + + $voucher = $fund->makeVoucher(); + $identity = $this->makeIdentity($this->makeUniqueEmail()); + + Event::dispatch(new VoucherSendToEmailBySponsorEvent($voucher, $identity->email)); + + $emailLog = $this->findEmailLog($identity, SendVoucherBySponsorMail::class, $startDate); + + $this->assertStringNotContainsString(':voucher_amount_locale', $emailLog->content); + $this->assertStringNotContainsString(':expiration_date', $emailLog->content); + } + + /** + * @return void + */ + public function testSponsorProductVoucherShareMailIncludesProductAndProvider(): void + { + $startDate = now(); + $organization = $this->makeTestOrganization($this->makeIdentity()); + $fund = $this->makeTestFund($organization, fundConfigsData: [ + 'show_qr_code' => true, + ]); + + $product = $this->makeTestProviderWithProducts(1)[0]; + $voucher = $this->makeTestProductVoucher($fund, productId: $product->id); + $identity = $this->makeIdentity($this->makeUniqueEmail()); + + Event::dispatch(new VoucherSendToEmailBySponsorEvent($voucher, $identity->email)); + + $emailLog = $this->findEmailLog($identity, SendProductVoucherBySponsorMail::class, $startDate); + + $this->assertStringContainsString($product->name, $emailLog->content); + $this->assertStringContainsString($product->organization->name, $emailLog->content); + } +}