"')
+ ->defaultValue(['goodwill', 'correction', 'promotion', 'other'])
+ ->scalarPrototype()->cannotBeEmpty()->end()
+ ->end()
+ ->arrayNode('triggers')
+ ->info('Earning trigger event classes. Each class must extend Setono\SyliusLoyaltyPlugin\Event\Trigger\EarningTriggerEvent')
+ ->defaultValue([])
+ ->scalarPrototype()->cannotBeEmpty()->end()
+ ->end()
+ ->booleanNode('retain_anonymized_ledger')
+ ->info('On customer deletion, keep de-identified ledger rows (type, points, dates, channel) linked to an opaque account token for accounting continuity, instead of deleting everything (the default)')
+ ->defaultFalse()
+ ->end()
+ ->arrayNode('referral')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('query_parameter')
+ ->info('The query parameter recognized as a referral code on any shop URL, e.g. /products/foo?ref=CODE')
+ ->defaultValue('ref')
+ ->cannotBeEmpty()
+ ->end()
+ ->booleanNode('registration_ip_check')
+ ->info('Enable the registration-IP fraud check. Opt-in: it stores a salted IP hash on the referral, purged after 90 days')
+ ->defaultFalse()
+ ->end()
+ ->scalarNode('ip_hash_salt')
+ ->info('Salt for the registration-IP hash; set your own secret when enabling the IP check')
+ ->defaultValue('%kernel.secret%')
+ ->cannotBeEmpty()
+ ->end()
+ ->integerNode('reward_cap')
+ ->info('Maximum rewarded referrals per referrer per 30 days before the cap fraud check flags')
+ ->defaultValue(10)
+ ->end()
+ ->end()
+ ->end()
+ ->arrayNode('expression_editor')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->scalarNode('cdn_base_url')
+ ->info('Base URL for the version-pinned ESM imports (CodeMirror) used by the admin expression editor. Point it at a self-hosted copy for intranet or strict-CSP setups')
+ ->defaultValue('https://esm.sh')
+ ->cannotBeEmpty()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
;
+ $this->addResourcesSection($rootNode);
+
return $treeBuilder;
}
+
+ private function addResourcesSection(ArrayNodeDefinition $rootNode): void
+ {
+ /** @var NodeBuilder $resources */
+ $resources = $rootNode
+ ->children()
+ ->arrayNode('resources')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ;
+
+ $this->addResourceNode($resources, 'account', LoyaltyAccount::class, LoyaltyAccountInterface::class, LoyaltyAccountRepository::class);
+ $this->addResourceNode($resources, 'program', LoyaltyProgram::class, LoyaltyProgramInterface::class, LoyaltyProgramRepository::class, LoyaltyProgramType::class);
+ $this->addResourceNode($resources, 'transaction', LoyaltyTransaction::class, LoyaltyTransactionInterface::class, LoyaltyTransactionRepository::class);
+ $this->addResourceNode($resources, 'earning_rule', EarningRule::class, EarningRuleInterface::class, EarningRuleRepository::class, EarningRuleType::class);
+ $this->addResourceNode($resources, 'earning_rule_condition', EarningRuleCondition::class, EarningRuleConditionInterface::class, null, EarningRuleConditionType::class);
+ $this->addResourceNode($resources, 'dry_run_result', DryRunResult::class, DryRunResultInterface::class);
+ $this->addResourceNode($resources, 'referral', Referral::class, ReferralInterface::class, ReferralRepository::class);
+ $this->addResourceNode($resources, 'tier', Tier::class, TierInterface::class, TierRepository::class, TierType::class, [
+ 'model' => TierTranslation::class,
+ 'interface' => TierTranslationInterface::class,
+ ]);
+ }
+
+ /**
+ * @param class-string $model
+ * @param class-string $interface
+ * @param class-string|null $repository
+ * @param class-string|null $form
+ * @param array{model: class-string, interface: class-string}|null $translation
+ */
+ private function addResourceNode(
+ NodeBuilder $resources,
+ string $name,
+ string $model,
+ string $interface,
+ ?string $repository = null,
+ ?string $form = null,
+ ?array $translation = null,
+ ): void {
+ $resourceNode = $resources->arrayNode($name)->addDefaultsIfNotSet();
+ $resourceChildren = $resourceNode->children();
+ $resourceChildren->variableNode('options');
+
+ $classes = $resourceChildren
+ ->arrayNode('classes')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ;
+
+ $classes->scalarNode('model')->defaultValue($model)->cannotBeEmpty();
+ $classes->scalarNode('interface')->defaultValue($interface)->cannotBeEmpty();
+ $classes->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty();
+ $classes->scalarNode('factory')->defaultValue(Factory::class)->cannotBeEmpty();
+
+ $formNode = $classes->scalarNode('form')->cannotBeEmpty();
+ if (null !== $form) {
+ $formNode->defaultValue($form);
+ }
+
+ $repositoryNode = $classes->scalarNode('repository')->cannotBeEmpty();
+ if (null !== $repository) {
+ $repositoryNode->defaultValue($repository);
+ }
+
+ if (null !== $translation) {
+ $translationClasses = $resourceChildren
+ ->arrayNode('translation')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->variableNode('options')->end()
+ ->arrayNode('classes')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ;
+
+ $translationClasses->scalarNode('model')->defaultValue($translation['model'])->cannotBeEmpty();
+ $translationClasses->scalarNode('interface')->defaultValue($translation['interface'])->cannotBeEmpty();
+ $translationClasses->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty();
+ $translationClasses->scalarNode('factory')->defaultValue(Factory::class)->cannotBeEmpty();
+ $translationClasses->scalarNode('repository')->cannotBeEmpty();
+ $translationClasses->scalarNode('form')->cannotBeEmpty();
+ }
+ }
}
diff --git a/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php b/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php
index 2398fd5..3a1ccdc 100644
--- a/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php
+++ b/src/DependencyInjection/SetonoSyliusLoyaltyExtension.php
@@ -4,25 +4,482 @@
namespace Setono\SyliusLoyaltyPlugin\DependencyInjection;
+use Setono\SyliusLoyaltyPlugin\EarningRule\Amount\AmountCalculatorInterface;
+use Setono\SyliusLoyaltyPlugin\EarningRule\Checker\ConditionCheckerInterface;
+use Setono\SyliusLoyaltyPlugin\Expression\Function\ExpressionFunctionInterface;
+use Setono\SyliusLoyaltyPlugin\Referral\FraudCheck\ReferralFraudCheckInterface;
+use Setono\SyliusLoyaltyPlugin\Tier\QualificationBasis\TierQualificationBasisInterface;
+use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension;
+use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
-use Symfony\Component\DependencyInjection\Extension\Extension;
+use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
-final class SetonoSyliusLoyaltyExtension extends Extension
+final class SetonoSyliusLoyaltyExtension extends AbstractResourceExtension implements PrependExtensionInterface
{
+ public function prepend(ContainerBuilder $container): void
+ {
+ $this->prependWinzouStateMachineConfig($container);
+ $this->prependSyliusUiConfig($container);
+ $this->prependSyliusGridConfig($container);
+
+ if ($container->hasExtension('twig')) {
+ $container->prependExtensionConfig('twig', [
+ 'form_themes' => ['@SetonoSyliusLoyaltyPlugin/form/theme.html.twig'],
+ ]);
+ }
+ }
+
+ /**
+ * Registers the plugin's admin grids.
+ */
+ private function prependSyliusGridConfig(ContainerBuilder $container): void
+ {
+ if (!$container->hasExtension('sylius_grid')) {
+ return;
+ }
+
+ $container->prependExtensionConfig('sylius_grid', [
+ 'grids' => [
+ 'setono_sylius_loyalty_admin_account' => [
+ 'driver' => [
+ 'name' => 'doctrine/orm',
+ 'options' => ['class' => '%setono_sylius_loyalty.model.account.class%'],
+ ],
+ 'sorting' => ['balance' => 'desc'],
+ 'fields' => [
+ 'customer' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.customer',
+ 'path' => 'customer.email',
+ 'sortable' => 'customer.email',
+ ],
+ 'channel' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.channel',
+ 'path' => 'channel.code',
+ ],
+ 'balance' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.balance_label',
+ 'sortable' => 'balance',
+ ],
+ 'lifetimeEarned' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.lifetime_earned',
+ 'sortable' => 'lifetimeEarned',
+ ],
+ 'tier' => [
+ 'type' => 'twig',
+ 'label' => 'setono_sylius_loyalty.ui.tier',
+ 'path' => 'tier',
+ 'options' => ['template' => '@SetonoSyliusLoyaltyPlugin/admin/grid/field/tier.html.twig'],
+ ],
+ 'enabled' => [
+ 'type' => 'twig',
+ 'label' => 'sylius.ui.enabled',
+ 'sortable' => 'enabled',
+ 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'],
+ ],
+ ],
+ 'filters' => [
+ 'customer' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.customer',
+ 'options' => ['fields' => ['customer.email']],
+ ],
+ 'enabled' => [
+ 'type' => 'boolean',
+ 'label' => 'sylius.ui.enabled',
+ ],
+ ],
+ 'actions' => [
+ 'item' => [
+ 'inspect' => [
+ 'type' => 'show',
+ 'label' => 'setono_sylius_loyalty.ui.inspect',
+ 'options' => [
+ 'link' => [
+ 'route' => 'setono_sylius_loyalty_admin_account_inspect',
+ 'parameters' => ['id' => 'resource.id'],
+ ],
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'setono_sylius_loyalty_admin_earning_rule' => [
+ 'driver' => [
+ 'name' => 'doctrine/orm',
+ 'options' => ['class' => '%setono_sylius_loyalty.model.earning_rule.class%'],
+ ],
+ 'sorting' => ['priority' => 'desc'],
+ 'fields' => [
+ 'name' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.name',
+ 'sortable' => 'name',
+ ],
+ 'trigger' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.trigger',
+ 'sortable' => 'trigger',
+ ],
+ 'scope' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.scope',
+ ],
+ 'priority' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.priority',
+ 'sortable' => 'priority',
+ ],
+ 'enabled' => [
+ 'type' => 'twig',
+ 'label' => 'sylius.ui.enabled',
+ 'sortable' => 'enabled',
+ 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'],
+ ],
+ 'dryRun' => [
+ 'type' => 'twig',
+ 'label' => 'setono_sylius_loyalty.ui.dry_run',
+ 'sortable' => 'dryRun',
+ 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'],
+ ],
+ ],
+ 'filters' => [
+ 'name' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.name',
+ 'options' => ['fields' => ['name']],
+ ],
+ 'enabled' => [
+ 'type' => 'boolean',
+ 'label' => 'sylius.ui.enabled',
+ ],
+ ],
+ 'actions' => [
+ 'main' => [
+ 'create' => ['type' => 'create'],
+ ],
+ 'item' => [
+ 'update' => ['type' => 'update'],
+ 'delete' => ['type' => 'delete'],
+ ],
+ ],
+ ],
+ 'setono_sylius_loyalty_admin_tier' => [
+ 'driver' => [
+ 'name' => 'doctrine/orm',
+ 'options' => ['class' => '%setono_sylius_loyalty.model.tier.class%'],
+ ],
+ 'sorting' => ['position' => 'desc'],
+ 'fields' => [
+ 'name' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.name',
+ 'sortable' => 'name',
+ ],
+ 'code' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.code',
+ 'sortable' => 'code',
+ ],
+ 'channel' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.channel',
+ 'path' => 'channel.code',
+ ],
+ 'position' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.position',
+ 'sortable' => 'position',
+ ],
+ 'threshold' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.form.tier.threshold',
+ 'sortable' => 'threshold',
+ ],
+ 'earningMultiplier' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.form.tier.earning_multiplier',
+ ],
+ 'enabled' => [
+ 'type' => 'twig',
+ 'label' => 'sylius.ui.enabled',
+ 'sortable' => 'enabled',
+ 'options' => ['template' => '@SyliusUi/Grid/Field/yesNo.html.twig'],
+ ],
+ ],
+ 'actions' => [
+ 'main' => [
+ 'create' => ['type' => 'create'],
+ ],
+ 'item' => [
+ 'update' => ['type' => 'update'],
+ 'delete' => ['type' => 'delete'],
+ ],
+ ],
+ ],
+ 'setono_sylius_loyalty_admin_referral' => [
+ 'driver' => [
+ 'name' => 'doctrine/orm',
+ 'options' => ['class' => '%setono_sylius_loyalty.model.referral.class%'],
+ ],
+ 'sorting' => ['createdAt' => 'desc'],
+ 'fields' => [
+ 'referrer' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.referrer',
+ 'path' => 'referrerAccount.customer.email',
+ ],
+ 'referee' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.referee',
+ 'path' => 'refereeCustomer.email',
+ ],
+ 'code' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.code',
+ ],
+ 'status' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.status',
+ 'sortable' => 'status',
+ ],
+ 'createdAt' => [
+ 'type' => 'datetime',
+ 'label' => 'sylius.ui.date',
+ 'sortable' => 'createdAt',
+ ],
+ 'override' => [
+ 'type' => 'twig',
+ 'label' => 'sylius.ui.actions',
+ 'path' => '.',
+ 'options' => ['template' => '@SetonoSyliusLoyaltyPlugin/admin/referral/_override.html.twig'],
+ ],
+ ],
+ 'filters' => [
+ 'status' => [
+ 'type' => 'select',
+ 'label' => 'sylius.ui.status',
+ 'form_options' => [
+ 'choices' => [
+ 'setono_sylius_loyalty.ui.referral_status.pending' => 'pending',
+ 'setono_sylius_loyalty.ui.referral_status.qualified' => 'qualified',
+ 'setono_sylius_loyalty.ui.referral_status.rewarded' => 'rewarded',
+ 'setono_sylius_loyalty.ui.referral_status.rejected' => 'rejected',
+ 'setono_sylius_loyalty.ui.referral_status.expired' => 'expired',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'setono_sylius_loyalty_admin_dry_run_result' => [
+ 'driver' => [
+ 'name' => 'doctrine/orm',
+ 'options' => ['class' => '%setono_sylius_loyalty.model.dry_run_result.class%'],
+ ],
+ 'sorting' => ['createdAt' => 'desc'],
+ 'fields' => [
+ 'rule' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.rule',
+ 'path' => 'rule.name',
+ ],
+ 'account' => [
+ 'type' => 'string',
+ 'label' => 'sylius.ui.customer',
+ 'path' => 'account.customer.email',
+ ],
+ 'points' => [
+ 'type' => 'string',
+ 'label' => 'setono_sylius_loyalty.ui.points',
+ 'sortable' => 'points',
+ ],
+ 'createdAt' => [
+ 'type' => 'datetime',
+ 'label' => 'sylius.ui.date',
+ 'sortable' => 'createdAt',
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Registers the plugin's template-event blocks.
+ */
+ private function prependSyliusUiConfig(ContainerBuilder $container): void
+ {
+ if (!$container->hasExtension('sylius_ui')) {
+ return;
+ }
+
+ $container->prependExtensionConfig('sylius_ui', [
+ 'events' => [
+ 'sylius.shop.cart.summary' => [
+ 'blocks' => [
+ 'setono_sylius_loyalty_cart_earn_hint' => [
+ 'template' => '@SetonoSyliusLoyaltyPlugin/shop/cart/_earn_hint.html.twig',
+ 'priority' => 3,
+ ],
+ 'setono_sylius_loyalty_redemption' => [
+ 'template' => '@SetonoSyliusLoyaltyPlugin/shop/cart/_redemption.html.twig',
+ 'priority' => 5,
+ ],
+ ],
+ ],
+ 'sylius.shop.checkout.complete.summary' => [
+ 'blocks' => [
+ 'setono_sylius_loyalty_redemption_summary' => [
+ 'template' => '@SetonoSyliusLoyaltyPlugin/shop/checkout/_redemption_summary.html.twig',
+ 'priority' => 5,
+ ],
+ ],
+ ],
+ 'sylius.shop.product.show.add_to_cart_form' => [
+ 'blocks' => [
+ // Deviation from the spec's "directly below the add-to-cart button":
+ // Sylius 1.14 has no template event there, so the hint renders just
+ // above the button instead
+ 'setono_sylius_loyalty_earn_hint' => [
+ 'template' => '@SetonoSyliusLoyaltyPlugin/shop/product/_earn_hint.html.twig',
+ 'priority' => -5,
+ ],
+ ],
+ ],
+ 'sylius.admin.customer.show.content' => [
+ 'blocks' => [
+ 'setono_sylius_loyalty_customer_loyalty' => [
+ 'template' => '@SetonoSyliusLoyaltyPlugin/admin/customer/_loyalty.html.twig',
+ 'priority' => -10,
+ ],
+ ],
+ ],
+ ],
+ ]);
+ }
+
public function load(array $configs, ContainerBuilder $container): void
{
/**
- * @psalm-suppress PossiblyNullArgument
- *
- * @var array{option: scalar} $config
+ * @var array{
+ * resources: array,
+ * manual_adjustment_reasons: list,
+ * triggers: list,
+ * expression_editor: array{cdn_base_url: string},
+ * referral: array{query_parameter: string, registration_ip_check: bool, ip_hash_salt: string, reward_cap: int},
+ * retain_anonymized_ledger: bool,
+ * } $config
*/
$config = $this->processConfiguration($this->getConfiguration([], $container), $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
- $container->setParameter('setono_sylius_loyalty.option', $config['option']);
+ $container->setParameter('setono_sylius_loyalty.manual_adjustment_reasons', $config['manual_adjustment_reasons']);
+ $container->setParameter('setono_sylius_loyalty.triggers', $config['triggers']);
+ $container->setParameter('setono_sylius_loyalty.expression_editor.cdn_base_url', $config['expression_editor']['cdn_base_url']);
+ $container->setParameter('setono_sylius_loyalty.retain_anonymized_ledger', $config['retain_anonymized_ledger']);
+ $container->setParameter('setono_sylius_loyalty.referral.query_parameter', $config['referral']['query_parameter']);
+ $container->setParameter('setono_sylius_loyalty.referral.registration_ip_check', $config['referral']['registration_ip_check']);
+ $container->setParameter('setono_sylius_loyalty.referral.ip_hash_salt', $config['referral']['ip_hash_salt']);
+ $container->setParameter('setono_sylius_loyalty.referral.reward_cap', $config['referral']['reward_cap']);
+
+ $this->registerResources(
+ 'setono_sylius_loyalty',
+ SyliusResourceBundle::DRIVER_DOCTRINE_ORM,
+ $config['resources'],
+ $container,
+ );
$loader->load('services.xml');
+
+ $container->registerForAutoconfiguration(ConditionCheckerInterface::class)
+ ->addTag('setono_sylius_loyalty.earning_condition');
+ $container->registerForAutoconfiguration(AmountCalculatorInterface::class)
+ ->addTag('setono_sylius_loyalty.earning_amount');
+ $container->registerForAutoconfiguration(ExpressionFunctionInterface::class)
+ ->addTag('setono_sylius_loyalty.expression_function');
+ $container->registerForAutoconfiguration(TierQualificationBasisInterface::class)
+ ->addTag('setono_sylius_loyalty.tier_qualification_basis');
+ $container->registerForAutoconfiguration(ReferralFraudCheckInterface::class)
+ ->addTag('setono_sylius_loyalty.referral_fraud_check');
+ }
+
+ /**
+ * Registers the plugin's state machine callbacks on the winzou engine (still Sylius'
+ * default graph engine). The symfony/workflow counterparts are plain event listener tags;
+ * database-level idempotency makes registering both engines safe.
+ */
+ private function prependWinzouStateMachineConfig(ContainerBuilder $container): void
+ {
+ if (!$container->hasExtension('winzou_state_machine')) {
+ return;
+ }
+
+ $container->prependExtensionConfig('winzou_state_machine', [
+ 'sylius_product_review' => [
+ 'callbacks' => [
+ 'after' => [
+ 'setono_sylius_loyalty_dispatch_review_trigger' => [
+ 'on' => ['accept'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\DispatchProductReviewApprovedTrigger', 'dispatch'],
+ 'args' => ['object'],
+ ],
+ ],
+ ],
+ ],
+ 'sylius_order_payment' => [
+ 'callbacks' => [
+ 'after' => [
+ 'setono_sylius_loyalty_award_order_points' => [
+ 'on' => ['pay'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\AwardOrderPointsListener', 'onOrderPaid'],
+ 'args' => ['object'],
+ ],
+ 'setono_sylius_loyalty_clawback_on_refund' => [
+ 'on' => ['refund'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\ClawbackListener', 'clawback'],
+ 'args' => ['object'],
+ ],
+ ],
+ ],
+ ],
+ 'sylius_order' => [
+ 'callbacks' => [
+ 'after' => [
+ 'setono_sylius_loyalty_award_order_points_fulfilled' => [
+ 'on' => ['fulfill'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\AwardOrderPointsListener', 'onOrderFulfilled'],
+ 'args' => ['object'],
+ ],
+ 'setono_sylius_loyalty_rollback_redemption' => [
+ 'on' => ['cancel'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\RollbackRedemptionListener', 'rollback'],
+ 'args' => ['object'],
+ ],
+ 'setono_sylius_loyalty_clawback_on_cancel' => [
+ 'on' => ['cancel'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\ClawbackListener', 'clawback'],
+ 'args' => ['object'],
+ ],
+ ],
+ ],
+ ],
+ 'sylius_order_checkout' => [
+ 'callbacks' => [
+ // A "before" callback so a failed debit aborts the completion
+ 'before' => [
+ 'setono_sylius_loyalty_redeem_points' => [
+ 'on' => ['complete'],
+ 'do' => ['@Setono\SyliusLoyaltyPlugin\EventListener\RedeemPointsListener', 'redeem'],
+ 'args' => ['object'],
+ ],
+ ],
+ ],
+ ],
+ ]);
}
}
diff --git a/src/EarningRule/ActionTriggerProcessor.php b/src/EarningRule/ActionTriggerProcessor.php
new file mode 100644
index 0000000..008e1eb
--- /dev/null
+++ b/src/EarningRule/ActionTriggerProcessor.php
@@ -0,0 +1,104 @@
+channelResolver->resolve($event);
+ if (null === $channel) {
+ // An expected data situation (e.g. a new customer in a multi-channel shop), not a bug
+ $this->logger->warning(sprintf(
+ '[Loyalty] No channel could be resolved for the "%s" trigger (customer: %s); nothing was awarded',
+ $event::getCode(),
+ (string) $event->getCustomer()->getEmail(),
+ ));
+
+ return null;
+ }
+
+ $rules = $this->ruleRepository->findForEvaluation($channel, $event::getCode());
+ if ([] === $rules) {
+ return null;
+ }
+
+ $account = $this->accountProvider->getByCustomerAndChannel($event->getCustomer(), $channel);
+ $program = $this->programProvider->getByChannel($channel);
+
+ $context = new EarningContext(
+ channel: $channel,
+ customer: $event->getCustomer(),
+ account: $account,
+ context: $this->contextVariables($event),
+ );
+
+ $result = $this->evaluator->evaluate($rules, $context, $program);
+
+ $this->dryRunLogger->log($result, $account);
+
+ if ($result->points <= 0) {
+ return null;
+ }
+
+ return $this->ledger->earnAction(
+ $account,
+ $result->points,
+ $event->getSourceIdentifier(),
+ $result->rulesBreakdown,
+ self::expiresAt($program),
+ );
+ }
+
+ /**
+ * The subclass's own public properties are the trigger's typed expression context.
+ *
+ * @return array
+ */
+ private function contextVariables(EarningTriggerEvent $event): array
+ {
+ $variables = [];
+ foreach ((new \ReflectionClass($event))->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
+ if (EarningTriggerEvent::class === $property->getDeclaringClass()->getName()) {
+ continue;
+ }
+
+ /** @var mixed $value */
+ $value = $property->getValue($event);
+ $variables[$property->getName()] = $value;
+ }
+
+ return $variables;
+ }
+
+ private static function expiresAt(LoyaltyProgramInterface $program): ?\DateTimeImmutable
+ {
+ $days = $program->getPointsExpiryDays();
+
+ return null === $days ? null : new \DateTimeImmutable(sprintf('+%d days', $days));
+ }
+}
diff --git a/src/EarningRule/ActionTriggerProcessorInterface.php b/src/EarningRule/ActionTriggerProcessorInterface.php
new file mode 100644
index 0000000..de27673
--- /dev/null
+++ b/src/EarningRule/ActionTriggerProcessorInterface.php
@@ -0,0 +1,19 @@
+ $configuration
+ */
+ public function calculate(array $configuration, AmountCalculationInput $input): float;
+}
diff --git a/src/EarningRule/Amount/AmountCalculatorRegistry.php b/src/EarningRule/Amount/AmountCalculatorRegistry.php
new file mode 100644
index 0000000..d626950
--- /dev/null
+++ b/src/EarningRule/Amount/AmountCalculatorRegistry.php
@@ -0,0 +1,29 @@
+
+ */
+final class AmountCalculatorRegistry extends CompositeService implements AmountCalculatorRegistryInterface
+{
+ public function get(string $type): ?AmountCalculatorInterface
+ {
+ foreach ($this->services as $calculator) {
+ if ($calculator->getType() === $type) {
+ return $calculator;
+ }
+ }
+
+ return null;
+ }
+
+ public function all(): array
+ {
+ return $this->services;
+ }
+}
diff --git a/src/EarningRule/Amount/AmountCalculatorRegistryInterface.php b/src/EarningRule/Amount/AmountCalculatorRegistryInterface.php
new file mode 100644
index 0000000..3f4a9ef
--- /dev/null
+++ b/src/EarningRule/Amount/AmountCalculatorRegistryInterface.php
@@ -0,0 +1,15 @@
+
+ */
+ public function all(): array;
+}
diff --git a/src/EarningRule/Amount/ExpressionAmountCalculator.php b/src/EarningRule/Amount/ExpressionAmountCalculator.php
new file mode 100644
index 0000000..6e8dcac
--- /dev/null
+++ b/src/EarningRule/Amount/ExpressionAmountCalculator.php
@@ -0,0 +1,61 @@
+ 50000 ? floor(basis / 50) : floor(basis / 100)". The "basis" variable holds the
+ * rule's claimed basis.
+ */
+final class ExpressionAmountCalculator implements AmountCalculatorInterface
+{
+ public const TYPE = 'expression';
+
+ public function __construct(
+ private readonly ExpressionEvaluatorInterface $expressionEvaluator,
+ private readonly LoggerInterface $logger,
+ ) {
+ }
+
+ public function getType(): string
+ {
+ return self::TYPE;
+ }
+
+ public function getLabel(): string
+ {
+ return 'setono_sylius_loyalty.form.earning_rule.amount.expression';
+ }
+
+ public function getConfigurationFormType(): ?string
+ {
+ return null; // set when the admin forms ship
+ }
+
+ public function calculate(array $configuration, AmountCalculationInput $input): float
+ {
+ $expression = $configuration['expression'] ?? null;
+ if (!is_string($expression) || '' === $expression) {
+ return 0.0;
+ }
+
+ $result = $this->expressionEvaluator->evaluate($expression, $input->context, $input->basisAmount);
+
+ if (!is_int($result) && !is_float($result)) {
+ $this->logger->warning(sprintf(
+ '[Loyalty] The amount expression "%s" evaluated to a non-numeric value (%s); the rule contributes nothing',
+ $expression,
+ get_debug_type($result),
+ ));
+
+ return 0.0;
+ }
+
+ return (float) $result;
+ }
+}
diff --git a/src/EarningRule/Amount/FixedAmountCalculator.php b/src/EarningRule/Amount/FixedAmountCalculator.php
new file mode 100644
index 0000000..5cefe3c
--- /dev/null
+++ b/src/EarningRule/Amount/FixedAmountCalculator.php
@@ -0,0 +1,39 @@
+units;
+ }
+}
diff --git a/src/EarningRule/Amount/MultiplierAmountCalculator.php b/src/EarningRule/Amount/MultiplierAmountCalculator.php
new file mode 100644
index 0000000..c3060d0
--- /dev/null
+++ b/src/EarningRule/Amount/MultiplierAmountCalculator.php
@@ -0,0 +1,48 @@
+ $configuration
+ */
+ public static function factor(array $configuration): float
+ {
+ $factor = $configuration['factor'] ?? null;
+ if (!is_int($factor) && !is_float($factor)) {
+ return 1.0;
+ }
+
+ return (float) $factor;
+ }
+}
diff --git a/src/EarningRule/Amount/PerAmountCalculator.php b/src/EarningRule/Amount/PerAmountCalculator.php
new file mode 100644
index 0000000..2c583c0
--- /dev/null
+++ b/src/EarningRule/Amount/PerAmountCalculator.php
@@ -0,0 +1,40 @@
+basisAmount / $perAmount * (float) $points;
+ }
+}
diff --git a/src/EarningRule/Basis/EligibleBasis.php b/src/EarningRule/Basis/EligibleBasis.php
new file mode 100644
index 0000000..ea886ae
--- /dev/null
+++ b/src/EarningRule/Basis/EligibleBasis.php
@@ -0,0 +1,27 @@
+ $itemAmounts order item id => eligible amount in minor units
+ */
+ public function __construct(
+ public readonly array $itemAmounts,
+ public readonly int $extraAmount = 0,
+ ) {
+ }
+
+ public function getTotal(): int
+ {
+ return (int) array_sum($this->itemAmounts) + $this->extraAmount;
+ }
+}
diff --git a/src/EarningRule/Basis/EligibleBasisCalculator.php b/src/EarningRule/Basis/EligibleBasisCalculator.php
new file mode 100644
index 0000000..56a448b
--- /dev/null
+++ b/src/EarningRule/Basis/EligibleBasisCalculator.php
@@ -0,0 +1,48 @@
+isIncludeTaxes();
+
+ $itemAmounts = [];
+ $itemsSum = 0;
+ foreach ($order->getItems() as $item) {
+ if (!$item instanceof OrderItemInterface || null === $item->getId()) {
+ continue;
+ }
+
+ // getTotal() is the discounted amount: unit prices, distributed promotions, the
+ // distributed loyalty redemption, and non-neutral taxes; getTaxTotal() covers both
+ // included (neutral) and added (non-neutral) taxes
+ $amount = $item->getTotal();
+ if (!$includeTaxes) {
+ $amount -= $item->getTaxTotal();
+ }
+
+ $amount = max(0, $amount);
+ $itemAmounts[(int) $item->getId()] = $amount;
+ $itemsSum += $amount;
+ }
+
+ if (LoyaltyProgramInterface::EARNING_BASIS_ITEMS_TOTAL === $program->getEarningBasis()) {
+ return new EligibleBasis($itemAmounts);
+ }
+
+ $orderTotal = $order->getTotal();
+ if (!$includeTaxes) {
+ $orderTotal -= $order->getTaxTotal();
+ }
+
+ return new EligibleBasis($itemAmounts, max(0, $orderTotal - $itemsSum));
+ }
+}
diff --git a/src/EarningRule/Basis/EligibleBasisCalculatorInterface.php b/src/EarningRule/Basis/EligibleBasisCalculatorInterface.php
new file mode 100644
index 0000000..82eadfb
--- /dev/null
+++ b/src/EarningRule/Basis/EligibleBasisCalculatorInterface.php
@@ -0,0 +1,19 @@
+order) {
+ return false;
+ }
+
+ /** @var list $taxons */
+ $taxons = array_values(array_filter((array) ($configuration['taxons'] ?? []), is_string(...)));
+ if ([] === $taxons) {
+ return false;
+ }
+
+ foreach ($context->order->getItems() as $item) {
+ if (!$item instanceof OrderItemInterface) {
+ continue;
+ }
+
+ $product = $item->getProduct();
+ if (!$product instanceof ProductInterface) {
+ continue;
+ }
+
+ foreach ($product->getTaxons() as $taxon) {
+ if (in_array($taxon->getCode(), $taxons, true)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return false;
+ }
+
+ public function requiresCart(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/EarningRule/Checker/ConditionCheckerInterface.php b/src/EarningRule/Checker/ConditionCheckerInterface.php
new file mode 100644
index 0000000..4274563
--- /dev/null
+++ b/src/EarningRule/Checker/ConditionCheckerInterface.php
@@ -0,0 +1,46 @@
+ $configuration
+ */
+ public function check(array $configuration, EarningContext $context): bool;
+
+ /**
+ * Whether this condition needs a customer. The earn-hint pipeline excludes rules whose
+ * conditions need what the hint context lacks — hints may understate, never overstate.
+ */
+ public function requiresCustomer(): bool;
+
+ /**
+ * Whether this condition needs the real cart (product-page hints evaluate a synthetic
+ * one-item cart).
+ */
+ public function requiresCart(): bool;
+}
diff --git a/src/EarningRule/Checker/ConditionCheckerRegistry.php b/src/EarningRule/Checker/ConditionCheckerRegistry.php
new file mode 100644
index 0000000..8b538f8
--- /dev/null
+++ b/src/EarningRule/Checker/ConditionCheckerRegistry.php
@@ -0,0 +1,29 @@
+
+ */
+final class ConditionCheckerRegistry extends CompositeService implements ConditionCheckerRegistryInterface
+{
+ public function get(string $type): ?ConditionCheckerInterface
+ {
+ foreach ($this->services as $checker) {
+ if ($checker->getType() === $type) {
+ return $checker;
+ }
+ }
+
+ return null;
+ }
+
+ public function all(): array
+ {
+ return $this->services;
+ }
+}
diff --git a/src/EarningRule/Checker/ConditionCheckerRegistryInterface.php b/src/EarningRule/Checker/ConditionCheckerRegistryInterface.php
new file mode 100644
index 0000000..34d8759
--- /dev/null
+++ b/src/EarningRule/Checker/ConditionCheckerRegistryInterface.php
@@ -0,0 +1,15 @@
+
+ */
+ public function all(): array;
+}
diff --git a/src/EarningRule/Checker/CustomerGroupConditionChecker.php b/src/EarningRule/Checker/CustomerGroupConditionChecker.php
new file mode 100644
index 0000000..d12f4e9
--- /dev/null
+++ b/src/EarningRule/Checker/CustomerGroupConditionChecker.php
@@ -0,0 +1,50 @@
+customer?->getGroup();
+ if (null === $group) {
+ return false;
+ }
+
+ /** @var list $groups */
+ $groups = array_values(array_filter((array) ($configuration['groups'] ?? []), is_string(...)));
+
+ return in_array($group->getCode(), $groups, true);
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return true;
+ }
+
+ public function requiresCart(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/EarningRule/Checker/DateWindowConditionChecker.php b/src/EarningRule/Checker/DateWindowConditionChecker.php
new file mode 100644
index 0000000..3380c3b
--- /dev/null
+++ b/src/EarningRule/Checker/DateWindowConditionChecker.php
@@ -0,0 +1,71 @@
+getNow();
+
+ $from = self::parseDate($configuration['from'] ?? null);
+ if (null !== $from && $now < $from) {
+ return false;
+ }
+
+ $until = self::parseDate($configuration['until'] ?? null);
+ if (null !== $until && $now > $until) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static function parseDate(mixed $value): ?\DateTimeImmutable
+ {
+ if (!is_string($value) || '' === $value) {
+ return null;
+ }
+
+ try {
+ return new \DateTimeImmutable($value);
+ } catch (\Exception) {
+ return null;
+ }
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return false;
+ }
+
+ public function requiresCart(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/EarningRule/Checker/DayOfWeekConditionChecker.php b/src/EarningRule/Checker/DayOfWeekConditionChecker.php
new file mode 100644
index 0000000..877b9c4
--- /dev/null
+++ b/src/EarningRule/Checker/DayOfWeekConditionChecker.php
@@ -0,0 +1,51 @@
+ is_numeric($day) ? (int) $day : 0,
+ (array) ($configuration['days'] ?? []),
+ );
+
+ return in_array((int) $context->getNow()->format('N'), $days, true);
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return false;
+ }
+
+ public function requiresCart(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/EarningRule/Checker/ExpressionConditionChecker.php b/src/EarningRule/Checker/ExpressionConditionChecker.php
new file mode 100644
index 0000000..6098fb5
--- /dev/null
+++ b/src/EarningRule/Checker/ExpressionConditionChecker.php
@@ -0,0 +1,56 @@
+expressionEvaluator->evaluate($expression, $context);
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return true;
+ }
+
+ public function requiresCart(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/EarningRule/Checker/NthOrderConditionChecker.php b/src/EarningRule/Checker/NthOrderConditionChecker.php
new file mode 100644
index 0000000..2bd8512
--- /dev/null
+++ b/src/EarningRule/Checker/NthOrderConditionChecker.php
@@ -0,0 +1,103 @@
+order || null === $context->customer) {
+ return false;
+ }
+
+ $nth = $configuration['nth'] ?? null;
+ if (!is_int($nth) || $nth < 1) {
+ return false;
+ }
+
+ $position = $this->position($context);
+ if (0 === $position) {
+ return false;
+ }
+
+ if (true === ($configuration['every'] ?? false)) {
+ return 0 === $position % $nth;
+ }
+
+ return $position === $nth;
+ }
+
+ /**
+ * The 1-based position of the evaluated order among the customer's paid orders in the
+ * channel. When the evaluated order is not itself counted as paid yet (e.g. in the rule
+ * tester before payment), it is counted on top.
+ */
+ private function position(EarningContext $context): int
+ {
+ $paidOrders = (int) $this->entityManager->createQueryBuilder()
+ ->select('COUNT(o.id)')
+ ->from($this->orderClass, 'o')
+ ->andWhere('o.customer = :customer')
+ ->andWhere('o.channel = :channel')
+ ->andWhere('o.paymentState = :paymentState')
+ ->setParameter('customer', $context->customer)
+ ->setParameter('channel', $context->channel)
+ ->setParameter('paymentState', OrderPaymentStates::STATE_PAID)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $order = $context->order;
+ if (null !== $order && OrderPaymentStates::STATE_PAID !== $order->getPaymentState()) {
+ ++$paidOrders;
+ }
+
+ return $paidOrders;
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return true;
+ }
+
+ public function requiresCart(): bool
+ {
+ return false;
+ }
+}
diff --git a/src/EarningRule/Checker/OrderTotalAtLeastConditionChecker.php b/src/EarningRule/Checker/OrderTotalAtLeastConditionChecker.php
new file mode 100644
index 0000000..cdc28be
--- /dev/null
+++ b/src/EarningRule/Checker/OrderTotalAtLeastConditionChecker.php
@@ -0,0 +1,51 @@
+order) {
+ return false;
+ }
+
+ $amount = $configuration['amount'] ?? null;
+ if (!is_int($amount)) {
+ return false;
+ }
+
+ return $context->order->getTotal() >= $amount;
+ }
+
+ public function requiresCustomer(): bool
+ {
+ return false;
+ }
+
+ public function requiresCart(): bool
+ {
+ return true;
+ }
+}
diff --git a/src/EarningRule/DryRunLogger.php b/src/EarningRule/DryRunLogger.php
new file mode 100644
index 0000000..0afcf99
--- /dev/null
+++ b/src/EarningRule/DryRunLogger.php
@@ -0,0 +1,66 @@
+ $dryRunResultFactory
+ */
+ public function __construct(
+ private readonly FactoryInterface $dryRunResultFactory,
+ ManagerRegistry $managerRegistry,
+ ) {
+ $this->managerRegistry = $managerRegistry;
+ }
+
+ public function log(
+ EvaluationResult $result,
+ ?LoyaltyAccountInterface $account = null,
+ ?OrderInterface $order = null,
+ ): void {
+ if ([] === $result->dryRunEvaluations) {
+ return;
+ }
+
+ $lastPersisted = null;
+ foreach ($result->dryRunEvaluations as $evaluation) {
+ if (!$evaluation->matched) {
+ continue;
+ }
+
+ $dryRunResult = $this->dryRunResultFactory->createNew();
+ Assert::isInstanceOf($dryRunResult, DryRunResultInterface::class);
+
+ $dryRunResult->setRule($evaluation->rule);
+ $dryRunResult->setAccount($account);
+ $dryRunResult->setOrder($order);
+ $dryRunResult->setPoints((int) round($evaluation->points));
+ $dryRunResult->setDetails([
+ 'claimedBasis' => $evaluation->claimedBasis,
+ 'claimedUnits' => $evaluation->claimedUnits,
+ 'factor' => $evaluation->factor,
+ 'failedConditions' => $evaluation->failedConditions,
+ ]);
+
+ $this->getManager($dryRunResult)->persist($dryRunResult);
+ $lastPersisted = $dryRunResult;
+ }
+
+ if (null !== $lastPersisted) {
+ $this->getManager($lastPersisted)->flush();
+ }
+ }
+}
diff --git a/src/EarningRule/DryRunLoggerInterface.php b/src/EarningRule/DryRunLoggerInterface.php
new file mode 100644
index 0000000..8360b9b
--- /dev/null
+++ b/src/EarningRule/DryRunLoggerInterface.php
@@ -0,0 +1,21 @@
+ $itemAmounts order item id => eligible amount in minor units of
+ * the channel base currency (discounted, redemption excluded, per the program's
+ * earning basis)
+ * @param array $context typed context variables of the trigger event
+ * @param int $extraAmount non-item basis (e.g. shipping under the order_total earning
+ * basis); only ever feeds order-scoped rules
+ */
+ public function __construct(
+ public readonly ChannelInterface $channel,
+ public readonly ?CustomerInterface $customer = null,
+ public readonly ?LoyaltyAccountInterface $account = null,
+ public readonly ?OrderInterface $order = null,
+ public readonly array $itemAmounts = [],
+ public readonly array $context = [],
+ private readonly ?\DateTimeImmutable $now = null,
+ public readonly int $extraAmount = 0,
+ ) {
+ }
+
+ /**
+ * The evaluation time — overridable so the rule tester can preview scheduled rules.
+ */
+ public function getNow(): \DateTimeImmutable
+ {
+ return $this->now ?? new \DateTimeImmutable();
+ }
+
+ /**
+ * The total eligible basis in minor units.
+ */
+ public function getBasis(): int
+ {
+ return (int) array_sum($this->itemAmounts) + $this->extraAmount;
+ }
+}
diff --git a/src/EarningRule/EarningRuleEvaluator.php b/src/EarningRule/EarningRuleEvaluator.php
new file mode 100644
index 0000000..8c07be2
--- /dev/null
+++ b/src/EarningRule/EarningRuleEvaluator.php
@@ -0,0 +1,440 @@
+isEnabled() && $this->isInWindow($rule, $context)) {
+ $active[] = $rule;
+ }
+ }
+
+ $live = array_values(array_filter($active, static fn (EarningRuleInterface $rule): bool => !$rule->isDryRun()));
+
+ [$total, $evaluations] = $this->run($live, $context);
+
+ // Each dry-run rule is evaluated as if it were live — alongside the actual live rules,
+ // so claiming and stacking behave exactly as they would — but its effect never reaches
+ // the live result.
+ $dryRunEvaluations = [];
+ foreach ($active as $rule) {
+ if (!$rule->isDryRun()) {
+ continue;
+ }
+
+ [$dryTotal, $dryEvaluations] = $this->run([...$live, $rule], $context);
+
+ foreach ($dryEvaluations as $evaluation) {
+ if ($evaluation->rule === $rule) {
+ // For multiplier rules the contribution is the delta they cause on the total
+ if (null !== $evaluation->factor && $evaluation->applied) {
+ $evaluation = new RuleEvaluation(
+ $rule,
+ $evaluation->matched,
+ $evaluation->failedConditions,
+ $evaluation->claimedItems,
+ $evaluation->claimedBasis,
+ $evaluation->claimedUnits,
+ $dryTotal - $total,
+ $evaluation->factor,
+ $evaluation->applied,
+ );
+ }
+
+ $dryRunEvaluations[] = $evaluation;
+ }
+ }
+ }
+
+ // The tier's earning multiplier applies after all rules, immediately before rounding
+ $tierMultiplier = $context->account?->getTier()?->getEarningMultiplier() ?? 1.0;
+
+ return new EvaluationResult(
+ $this->round($total * $tierMultiplier, $program),
+ $this->buildBreakdown($evaluations),
+ $evaluations,
+ $dryRunEvaluations,
+ );
+ }
+
+ /**
+ * Runs a full evaluation of the given rules and returns the unrounded total and the
+ * per-rule detail.
+ *
+ * @param list $rules
+ *
+ * @return array{0: float, 1: list}
+ */
+ private function run(array $rules, EarningContext $context): array
+ {
+ $matched = [];
+ $failedConditions = [];
+ foreach ($rules as $key => $rule) {
+ $failedConditions[$key] = $this->failedConditions($rule, $context);
+ $matched[$key] = $this->conditionsPass($rule, $failedConditions[$key]);
+ }
+
+ $baseRules = [];
+ $multiplierRules = [];
+ foreach ($rules as $key => $rule) {
+ if (MultiplierAmountCalculator::TYPE === $rule->getAmountType()) {
+ $multiplierRules[$key] = $rule;
+ } else {
+ $baseRules[$key] = $rule;
+ }
+ }
+
+ // Claiming: every item is claimed at the most specific matching scope; the competitor
+ // set at that scope is resolved by stackability. Unclaimed items form the remainder of
+ // the order-scoped rules.
+ /** @var array> $claimedItems rule key => item id => amount */
+ $claimedItems = [];
+
+ /** @var array $claimedUnits rule key => units */
+ $claimedUnits = [];
+
+ $remainderBasis = $context->extraAmount;
+
+ $items = $this->itemsById($context);
+ foreach ($context->itemAmounts as $itemId => $amount) {
+ $item = $items[$itemId] ?? null;
+
+ $competitors = $this->itemCompetitors($baseRules, $matched, $item);
+ if ([] === $competitors) {
+ $remainderBasis += $amount;
+
+ continue;
+ }
+
+ foreach ($this->resolveStacking($competitors) as $key => $rule) {
+ $claimedItems[$key][$itemId] = $amount;
+ $claimedUnits[$key] = ($claimedUnits[$key] ?? 0) + ($item?->getQuantity() ?? 1);
+ }
+ }
+
+ // Order-scoped rules compete for the remainder (or for the whole basis in orderless
+ // action-trigger contexts)
+ $orderScopedMatched = array_filter(
+ $baseRules,
+ static fn (EarningRuleInterface $rule, int $key): bool => $matched[$key] && EarningRuleInterface::SCOPE_ORDER === $rule->getScope(),
+ \ARRAY_FILTER_USE_BOTH,
+ );
+
+ $orderScopedApplied = $this->resolveStacking($orderScopedMatched);
+
+ // Base points
+ $baseTotal = 0.0;
+ $points = [];
+ $applied = [];
+ foreach ($baseRules as $key => $rule) {
+ $isOrderScoped = EarningRuleInterface::SCOPE_ORDER === $rule->getScope();
+ $applied[$key] = $matched[$key] && ($isOrderScoped ? isset($orderScopedApplied[$key]) : isset($claimedItems[$key]));
+
+ if (!$applied[$key]) {
+ $points[$key] = 0.0;
+
+ continue;
+ }
+
+ $input = new AmountCalculationInput(
+ $isOrderScoped ? $remainderBasis : (int) array_sum($claimedItems[$key] ?? []),
+ $isOrderScoped ? 1 : ($claimedUnits[$key] ?? 1),
+ $context,
+ );
+
+ $points[$key] = $this->calculate($rule, $input);
+ $baseTotal += $points[$key];
+ }
+
+ // Multipliers: stackable ones multiply cumulatively; any non-stackable wins alone
+ $matchedMultipliers = array_filter(
+ $multiplierRules,
+ static fn (int $key): bool => $matched[$key],
+ \ARRAY_FILTER_USE_KEY,
+ );
+
+ $appliedMultipliers = $this->resolveStacking($matchedMultipliers);
+
+ $factor = 1.0;
+ $factors = [];
+ foreach ($multiplierRules as $key => $rule) {
+ $applied[$key] = isset($appliedMultipliers[$key]);
+ $factors[$key] = MultiplierAmountCalculator::factor($rule->getAmountConfiguration());
+ if ($applied[$key]) {
+ $factor *= $factors[$key];
+ }
+ }
+
+ $evaluations = [];
+ foreach ($rules as $key => $rule) {
+ $isMultiplier = MultiplierAmountCalculator::TYPE === $rule->getAmountType();
+
+ $evaluations[] = new RuleEvaluation(
+ $rule,
+ $matched[$key],
+ $failedConditions[$key],
+ $claimedItems[$key] ?? [],
+ EarningRuleInterface::SCOPE_ORDER === $rule->getScope() ? $remainderBasis : (int) array_sum($claimedItems[$key] ?? []),
+ $claimedUnits[$key] ?? 0,
+ $points[$key] ?? 0.0,
+ $isMultiplier ? $factors[$key] : null,
+ $applied[$key] ?? false,
+ );
+ }
+
+ return [$baseTotal * $factor, $evaluations];
+ }
+
+ /**
+ * The matched base rules competing for the given item, taken from the most specific
+ * matching scope (product > taxon).
+ *
+ * @param array $baseRules
+ * @param array $matched
+ *
+ * @return array
+ */
+ private function itemCompetitors(array $baseRules, array $matched, ?OrderItemInterface $item): array
+ {
+ if (null === $item) {
+ return [];
+ }
+
+ foreach ([EarningRuleInterface::SCOPE_PRODUCT, EarningRuleInterface::SCOPE_TAXON] as $scope) {
+ $competitors = array_filter(
+ $baseRules,
+ fn (EarningRuleInterface $rule, int $key): bool => $matched[$key] &&
+ $rule->getScope() === $scope &&
+ $this->scopeMatchesItem($rule, $item),
+ \ARRAY_FILTER_USE_BOTH,
+ );
+
+ if ([] !== $competitors) {
+ return $competitors;
+ }
+ }
+
+ return [];
+ }
+
+ private function scopeMatchesItem(EarningRuleInterface $rule, OrderItemInterface $item): bool
+ {
+ $product = $item->getProduct();
+ if (!$product instanceof ProductInterface) {
+ return false;
+ }
+
+ $configuration = $rule->getScopeConfiguration();
+
+ if (EarningRuleInterface::SCOPE_PRODUCT === $rule->getScope()) {
+ /** @var list $products */
+ $products = array_values(array_filter((array) ($configuration['products'] ?? []), is_string(...)));
+
+ return in_array($product->getCode(), $products, true);
+ }
+
+ if (EarningRuleInterface::SCOPE_TAXON === $rule->getScope()) {
+ /** @var list $taxons */
+ $taxons = array_values(array_filter((array) ($configuration['taxons'] ?? []), is_string(...)));
+
+ foreach ($product->getTaxons() as $taxon) {
+ if (in_array($taxon->getCode(), $taxons, true)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * All stackable rules apply and sum; if any rule is non-stackable, the highest-priority
+ * (then lowest id) non-stackable rule applies alone.
+ *
+ * @param array $competitors
+ *
+ * @return array the applied subset, keyed as the input
+ */
+ private function resolveStacking(array $competitors): array
+ {
+ $nonStackable = array_filter(
+ $competitors,
+ static fn (EarningRuleInterface $rule): bool => !$rule->isStackable(),
+ );
+
+ if ([] === $nonStackable) {
+ return $competitors;
+ }
+
+ uasort(
+ $nonStackable,
+ static fn (EarningRuleInterface $a, EarningRuleInterface $b): int => [-$a->getPriority(), $a->getId() ?? \PHP_INT_MAX]
+ <=> [-$b->getPriority(), $b->getId() ?? \PHP_INT_MAX],
+ );
+
+ $winnerKey = array_key_first($nonStackable);
+
+ return [$winnerKey => $nonStackable[$winnerKey]];
+ }
+
+ /**
+ * @return list the condition types that failed
+ */
+ private function failedConditions(EarningRuleInterface $rule, EarningContext $context): array
+ {
+ $failed = [];
+ foreach ($rule->getConditions() as $condition) {
+ $type = $condition->getType();
+ if (null === $type) {
+ continue;
+ }
+
+ $checker = $this->conditionCheckers->get($type);
+ if (null === $checker) {
+ $this->logger->warning(sprintf(
+ '[Loyalty] Unknown condition type "%s" on earning rule (id: %s); the condition fails',
+ $type,
+ (string) ($rule->getId() ?? 'unsaved'),
+ ));
+ $failed[] = $type;
+
+ continue;
+ }
+
+ if (!$checker->check($condition->getConfiguration(), $context)) {
+ $failed[] = $type;
+ }
+ }
+
+ return $failed;
+ }
+
+ /**
+ * @param list $failedConditions
+ */
+ private function conditionsPass(EarningRuleInterface $rule, array $failedConditions): bool
+ {
+ $conditionCount = $rule->getConditions()->count();
+ if (0 === $conditionCount) {
+ return true;
+ }
+
+ if (EarningRuleInterface::CONDITIONS_MATCH_ANY === $rule->getConditionsMatch()) {
+ return count($failedConditions) < $conditionCount;
+ }
+
+ return [] === $failedConditions;
+ }
+
+ private function calculate(EarningRuleInterface $rule, AmountCalculationInput $input): float
+ {
+ $amountType = $rule->getAmountType();
+ if (null === $amountType) {
+ return 0.0;
+ }
+
+ $calculator = $this->amountCalculators->get($amountType);
+ if (null === $calculator) {
+ $this->logger->warning(sprintf(
+ '[Loyalty] Unknown amount type "%s" on earning rule (id: %s); the rule contributes nothing',
+ $amountType,
+ (string) ($rule->getId() ?? 'unsaved'),
+ ));
+
+ return 0.0;
+ }
+
+ return $calculator->calculate($rule->getAmountConfiguration(), $input);
+ }
+
+ private function isInWindow(EarningRuleInterface $rule, EarningContext $context): bool
+ {
+ $now = $context->getNow();
+
+ $startsAt = $rule->getStartsAt();
+ if (null !== $startsAt && $now < $startsAt) {
+ return false;
+ }
+
+ $endsAt = $rule->getEndsAt();
+
+ return null === $endsAt || $now <= $endsAt;
+ }
+
+ private function round(float $points, LoyaltyProgramInterface $program): int
+ {
+ return match ($program->getRounding()) {
+ LoyaltyProgramInterface::ROUNDING_CEIL => (int) ceil($points),
+ LoyaltyProgramInterface::ROUNDING_ROUND => (int) round($points),
+ default => (int) floor($points),
+ };
+ }
+
+ /**
+ * @return array
+ */
+ private function itemsById(EarningContext $context): array
+ {
+ $items = [];
+ if (null !== $context->order) {
+ foreach ($context->order->getItems() as $item) {
+ if ($item instanceof OrderItemInterface && null !== $item->getId()) {
+ $items[(int) $item->getId()] = $item;
+ }
+ }
+ }
+
+ return $items;
+ }
+
+ /**
+ * @param list $evaluations
+ *
+ * @return array
+ */
+ private function buildBreakdown(array $evaluations): array
+ {
+ $rules = [];
+ $multipliers = [];
+ foreach ($evaluations as $evaluation) {
+ if (!$evaluation->applied) {
+ continue;
+ }
+
+ $id = (string) ($evaluation->rule->getId() ?? spl_object_id($evaluation->rule));
+ if (null !== $evaluation->factor) {
+ $multipliers[$id] = $evaluation->factor;
+ } else {
+ $rules[$id] = round($evaluation->points, 2);
+ }
+ }
+
+ return [
+ 'rules' => $rules,
+ 'multipliers' => $multipliers,
+ ];
+ }
+}
diff --git a/src/EarningRule/EarningRuleEvaluatorInterface.php b/src/EarningRule/EarningRuleEvaluatorInterface.php
new file mode 100644
index 0000000..755d1ed
--- /dev/null
+++ b/src/EarningRule/EarningRuleEvaluatorInterface.php
@@ -0,0 +1,22 @@
+ taxon > order specificity, base-rule stacking,
+ * multiplier rules, and the program's rounding. Read-only — never writes anything.
+ */
+interface EarningRuleEvaluatorInterface
+{
+ /**
+ * @param iterable $rules rules sharing the same trigger; disabled or
+ * out-of-window rules are ignored, dry-run rules are diverted to the dry-run result
+ */
+ public function evaluate(iterable $rules, EarningContext $context, LoyaltyProgramInterface $program): EvaluationResult;
+}
diff --git a/src/EarningRule/EvaluationResult.php b/src/EarningRule/EvaluationResult.php
new file mode 100644
index 0000000..2114469
--- /dev/null
+++ b/src/EarningRule/EvaluationResult.php
@@ -0,0 +1,26 @@
+ $rulesBreakdown recorded on the earn transaction: rule id =>
+ * points contributed, multipliers noted
+ * @param list $ruleEvaluations live rules, matched or not
+ * @param list $dryRunEvaluations what each dry-run rule would have contributed
+ */
+ public function __construct(
+ public readonly int $points,
+ public readonly array $rulesBreakdown,
+ public readonly array $ruleEvaluations,
+ public readonly array $dryRunEvaluations,
+ ) {
+ }
+}
diff --git a/src/EarningRule/RuleEvaluation.php b/src/EarningRule/RuleEvaluation.php
new file mode 100644
index 0000000..9d88aa5
--- /dev/null
+++ b/src/EarningRule/RuleEvaluation.php
@@ -0,0 +1,34 @@
+ $failedConditions condition types that did not pass
+ * @param array $claimedItems order item id => claimed basis amount (minor units)
+ * @param float $points the rule's (unrounded) contribution; 0 for multiplier rules
+ * @param float|null $factor the multiplier factor; null for base rules
+ * @param bool $applied whether the rule survived the stacking resolution
+ */
+ public function __construct(
+ public readonly EarningRuleInterface $rule,
+ public readonly bool $matched,
+ public readonly array $failedConditions,
+ public readonly array $claimedItems,
+ public readonly int $claimedBasis,
+ public readonly int $claimedUnits,
+ public readonly float $points,
+ public readonly ?float $factor,
+ public readonly bool $applied,
+ ) {
+ }
+}
diff --git a/src/Event/AwardingPoints.php b/src/Event/AwardingPoints.php
new file mode 100644
index 0000000..cc04d57
--- /dev/null
+++ b/src/Event/AwardingPoints.php
@@ -0,0 +1,79 @@
+ $rulesBreakdown
+ */
+ public function __construct(
+ private readonly LoyaltyAccountInterface $account,
+ private int $points,
+ private ?\DateTimeImmutable $expiresAt,
+ private readonly ?OrderInterface $order = null,
+ private readonly ?string $sourceIdentifier = null,
+ private readonly array $rulesBreakdown = [],
+ ) {
+ }
+
+ public function getAccount(): LoyaltyAccountInterface
+ {
+ return $this->account;
+ }
+
+ public function getPoints(): int
+ {
+ return $this->points;
+ }
+
+ public function setPoints(int $points): void
+ {
+ $this->points = $points;
+ }
+
+ public function getExpiresAt(): ?\DateTimeImmutable
+ {
+ return $this->expiresAt;
+ }
+
+ public function setExpiresAt(?\DateTimeImmutable $expiresAt): void
+ {
+ $this->expiresAt = $expiresAt;
+ }
+
+ /**
+ * The order being awarded for. Null for action-trigger earning.
+ */
+ public function getOrder(): ?OrderInterface
+ {
+ return $this->order;
+ }
+
+ /**
+ * The deduplication identifier. Null for order earning.
+ */
+ public function getSourceIdentifier(): ?string
+ {
+ return $this->sourceIdentifier;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRulesBreakdown(): array
+ {
+ return $this->rulesBreakdown;
+ }
+}
diff --git a/src/Event/CancellableTrait.php b/src/Event/CancellableTrait.php
new file mode 100644
index 0000000..940d028
--- /dev/null
+++ b/src/Event/CancellableTrait.php
@@ -0,0 +1,26 @@
+cancelled = true;
+ }
+
+ public function isCancelled(): bool
+ {
+ return $this->cancelled;
+ }
+}
diff --git a/src/Event/ClawingBackPoints.php b/src/Event/ClawingBackPoints.php
new file mode 100644
index 0000000..adeffe5
--- /dev/null
+++ b/src/Event/ClawingBackPoints.php
@@ -0,0 +1,51 @@
+account;
+ }
+
+ public function getPoints(): int
+ {
+ return $this->points;
+ }
+
+ public function setPoints(int $points): void
+ {
+ $this->points = $points;
+ }
+
+ public function getOrder(): ?OrderInterface
+ {
+ return $this->order;
+ }
+
+ public function getEarn(): CreditLoyaltyTransactionInterface
+ {
+ return $this->earn;
+ }
+}
diff --git a/src/Event/ExpiringPoints.php b/src/Event/ExpiringPoints.php
new file mode 100644
index 0000000..68bb946
--- /dev/null
+++ b/src/Event/ExpiringPoints.php
@@ -0,0 +1,41 @@
+account;
+ }
+
+ public function getLot(): CreditLoyaltyTransactionInterface
+ {
+ return $this->lot;
+ }
+
+ public function getPoints(): int
+ {
+ return $this->points;
+ }
+}
diff --git a/src/Event/ManualAdjustment.php b/src/Event/ManualAdjustment.php
new file mode 100644
index 0000000..a61a34a
--- /dev/null
+++ b/src/Event/ManualAdjustment.php
@@ -0,0 +1,19 @@
+account;
+ }
+
+ public function getPoints(): int
+ {
+ return $this->points;
+ }
+
+ public function getOrder(): OrderInterface
+ {
+ return $this->order;
+ }
+}
diff --git a/src/Event/RedemptionRolledBack.php b/src/Event/RedemptionRolledBack.php
new file mode 100644
index 0000000..ac9e720
--- /dev/null
+++ b/src/Event/RedemptionRolledBack.php
@@ -0,0 +1,19 @@
+customer;
+ }
+
+ public function getChannel(): ?ChannelInterface
+ {
+ return $this->channel;
+ }
+
+ public function getSourceIdentifier(): string
+ {
+ return $this->sourceIdentifier ?? static::getCode();
+ }
+}
diff --git a/src/Event/Trigger/ProductReviewApprovedTriggerEvent.php b/src/Event/Trigger/ProductReviewApprovedTriggerEvent.php
new file mode 100644
index 0000000..9383cf8
--- /dev/null
+++ b/src/Event/Trigger/ProductReviewApprovedTriggerEvent.php
@@ -0,0 +1,29 @@
+dispatch($order, LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_PAYMENT_PAID);
+ }
+
+ /**
+ * The winzou callback entry point for the sylius_order graph's "fulfill" transition.
+ */
+ public function onOrderFulfilled(OrderInterface $order): void
+ {
+ $this->dispatch($order, LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED);
+ }
+
+ public function onPaymentWorkflowCompleted(CompletedEvent $event): void
+ {
+ $order = $event->getSubject();
+ if ($order instanceof OrderInterface) {
+ $this->onOrderPaid($order);
+ }
+ }
+
+ public function onOrderWorkflowCompleted(CompletedEvent $event): void
+ {
+ $order = $event->getSubject();
+ if ($order instanceof OrderInterface) {
+ $this->onOrderFulfilled($order);
+ }
+ }
+
+ private function dispatch(OrderInterface $order, string $awardMoment): void
+ {
+ $channel = $order->getChannel();
+ $orderId = $order->getId();
+
+ if (null === $channel || !is_int($orderId) || null === $order->getCustomer()) {
+ return;
+ }
+
+ if ($this->programProvider->getByChannel($channel)->getAwardOrderPointsAt() !== $awardMoment) {
+ return;
+ }
+
+ $this->messageBus->dispatch(
+ new Envelope(new AwardOrderPoints($orderId), [new DispatchAfterCurrentBusStamp()]),
+ );
+ }
+}
diff --git a/src/EventListener/ClawbackListener.php b/src/EventListener/ClawbackListener.php
new file mode 100644
index 0000000..5bca11f
--- /dev/null
+++ b/src/EventListener/ClawbackListener.php
@@ -0,0 +1,70 @@
+transactionRepository->findEarnOrderTransaction($order);
+ if (null !== $earn && $earn->getPoints() > 0) {
+ $this->ledger->clawback($order, $earn->getPoints());
+ }
+
+ $this->clawbackReferralRewards($order);
+ }
+
+ /**
+ * Cancelling or refunding a referral's qualifying order claws back both parties' rewards
+ * (idempotent per credit via the (type, earn) unique constraint).
+ */
+ private function clawbackReferralRewards(OrderInterface $order): void
+ {
+ $referral = $this->referralRepository->findOneBy(['refereeFirstOrder' => $order]);
+ if (!$referral instanceof ReferralInterface || ReferralInterface::STATUS_REWARDED !== $referral->getStatus()) {
+ return;
+ }
+
+ foreach ($this->transactionRepository->findEarnReferralTransactions($referral) as $credit) {
+ if ($credit->getPoints() > 0) {
+ $this->ledger->clawbackCredit($credit, $order);
+ }
+ }
+ }
+
+ /**
+ * The symfony/workflow entry point.
+ */
+ public function onWorkflowCompleted(CompletedEvent $event): void
+ {
+ $order = $event->getSubject();
+ if ($order instanceof OrderInterface) {
+ $this->clawback($order);
+ }
+ }
+}
diff --git a/src/EventListener/CreateReferralOnRegistrationListener.php b/src/EventListener/CreateReferralOnRegistrationListener.php
new file mode 100644
index 0000000..5252e81
--- /dev/null
+++ b/src/EventListener/CreateReferralOnRegistrationListener.php
@@ -0,0 +1,96 @@
+ $accountRepository
+ * @param FactoryInterface $referralFactory
+ */
+ public function __construct(
+ private readonly RequestStack $requestStack,
+ private readonly RepositoryInterface $accountRepository,
+ private readonly ReferralRepositoryInterface $referralRepository,
+ private readonly FactoryInterface $referralFactory,
+ private readonly ChannelContextInterface $channelContext,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly LoggerInterface $logger,
+ private readonly bool $registrationIpCheck,
+ private readonly string $ipHashSalt,
+ ) {
+ }
+
+ public function __invoke(GenericEvent $event): void
+ {
+ $customer = $event->getSubject();
+ if (!$customer instanceof CustomerInterface) {
+ return;
+ }
+
+ $request = $this->requestStack->getMainRequest();
+ if (null === $request) {
+ return;
+ }
+
+ $code = $request->cookies->get(AttributionCookie::NAME);
+ if (!is_string($code) || !AttributionCookie::isValidFormat($code)) {
+ return;
+ }
+
+ $referrerAccount = $this->accountRepository->findOneBy(['referralCode' => $code]);
+ if (!$referrerAccount instanceof LoyaltyAccountInterface) {
+ return;
+ }
+
+ $channel = $this->channelContext->getChannel();
+
+ // Self-referral through one's own link: silently no referral (the fraud checks also
+ // guard the reward path)
+ if ($referrerAccount->getCustomer() === $customer) {
+ return;
+ }
+
+ if (null !== $this->referralRepository->findOneByRefereeAndChannel($customer, $channel)) {
+ return;
+ }
+
+ $referral = $this->referralFactory->createNew();
+ $referral->setReferrerAccount($referrerAccount);
+ $referral->setRefereeCustomer($customer);
+ $referral->setChannel($channel);
+ $referral->setCode($code);
+
+ if ($this->registrationIpCheck && null !== $request->getClientIp()) {
+ $referral->setRegistrationIpHash(hash('sha256', $this->ipHashSalt . $request->getClientIp()));
+ }
+
+ $this->entityManager->persist($referral);
+ $this->entityManager->flush();
+
+ $this->logger->info(sprintf(
+ '[Loyalty] Referral created: customer %s referred with code %s',
+ (string) $customer->getId(),
+ $code,
+ ));
+ }
+}
diff --git a/src/EventListener/CustomerDeletionListener.php b/src/EventListener/CustomerDeletionListener.php
new file mode 100644
index 0000000..d0bddea
--- /dev/null
+++ b/src/EventListener/CustomerDeletionListener.php
@@ -0,0 +1,79 @@
+ $accountClass
+ */
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly LoyaltyTransactionRepositoryInterface $transactionRepository,
+ private readonly bool $retainAnonymizedLedger,
+ private readonly string $accountClass,
+ ) {
+ }
+
+ public function __invoke(GenericEvent $event): void
+ {
+ $customer = $event->getSubject();
+ if (!$customer instanceof CustomerInterface) {
+ return;
+ }
+
+ /** @var list $accounts */
+ $accounts = $this->entityManager->getRepository($this->accountClass)->findBy(['customer' => $customer]);
+
+ foreach ($accounts as $account) {
+ if ($this->retainAnonymizedLedger) {
+ $this->anonymize($account);
+ } else {
+ $this->entityManager->remove($account);
+ }
+ }
+ }
+
+ private function anonymize(LoyaltyAccountInterface $account): void
+ {
+ $account->setCustomer(null);
+ $account->setAnonymizedToken(bin2hex(random_bytes(16)));
+ $account->setReferralCode(null);
+ $account->setEnabled(false);
+
+ foreach ($this->transactionRepository->findForReplay($account) as $transaction) {
+ if ($transaction instanceof EarnOrderLoyaltyTransactionInterface || $transaction instanceof RedeemLoyaltyTransactionInterface) {
+ $transaction->setOrder(null);
+ }
+
+ if ($transaction instanceof EarnActionLoyaltyTransactionInterface) {
+ $transaction->setSourceIdentifier(null);
+ }
+
+ if ($transaction instanceof ManualLoyaltyTransactionInterface) {
+ $transaction->setNote(null);
+ $transaction->setAdminUser(null);
+ }
+ }
+ }
+}
diff --git a/src/EventListener/DispatchClaimPastOrderPointsListener.php b/src/EventListener/DispatchClaimPastOrderPointsListener.php
new file mode 100644
index 0000000..591a70a
--- /dev/null
+++ b/src/EventListener/DispatchClaimPastOrderPointsListener.php
@@ -0,0 +1,52 @@
+getSubject();
+ if (!$customer instanceof CustomerInterface || null === $customer->getId()) {
+ return;
+ }
+
+ try {
+ $channel = $this->channelContext->getChannel();
+ } catch (ChannelNotFoundException) {
+ return;
+ }
+
+ if (null === $channel->getId() || !$this->programProvider->getByChannel($channel)->isRetroactiveGuestPoints()) {
+ return;
+ }
+
+ $this->messageBus->dispatch(new Envelope(
+ new ClaimPastOrderPoints((int) $customer->getId(), (int) $channel->getId()),
+ [new DispatchAfterCurrentBusStamp()],
+ ));
+ }
+}
diff --git a/src/EventListener/DispatchCustomerRegisteredTrigger.php b/src/EventListener/DispatchCustomerRegisteredTrigger.php
new file mode 100644
index 0000000..538d01c
--- /dev/null
+++ b/src/EventListener/DispatchCustomerRegisteredTrigger.php
@@ -0,0 +1,32 @@
+getSubject();
+ if (!$customer instanceof CustomerInterface) {
+ return;
+ }
+
+ $this->eventDispatcher->dispatch(new CustomerRegisteredTriggerEvent($customer));
+ }
+}
diff --git a/src/EventListener/DispatchProductReviewApprovedTrigger.php b/src/EventListener/DispatchProductReviewApprovedTrigger.php
new file mode 100644
index 0000000..8a1712f
--- /dev/null
+++ b/src/EventListener/DispatchProductReviewApprovedTrigger.php
@@ -0,0 +1,51 @@
+") makes double execution a no-op.
+ */
+final class DispatchProductReviewApprovedTrigger
+{
+ public function __construct(
+ private readonly EventDispatcherInterface $eventDispatcher,
+ ) {
+ }
+
+ /**
+ * The winzou callback entry point.
+ */
+ public function dispatch(ReviewInterface $review): void
+ {
+ $customer = $review->getAuthor();
+ $reviewId = $review->getId();
+
+ if (!$customer instanceof CustomerInterface || !is_int($reviewId)) {
+ return;
+ }
+
+ $this->eventDispatcher->dispatch(new ProductReviewApprovedTriggerEvent($customer, $reviewId));
+ }
+
+ /**
+ * The symfony/workflow entry point.
+ */
+ public function onWorkflowCompleted(CompletedEvent $event): void
+ {
+ $review = $event->getSubject();
+ if ($review instanceof ReviewInterface) {
+ $this->dispatch($review);
+ }
+ }
+}
diff --git a/src/EventListener/Doctrine/DiscriminatorMapListener.php b/src/EventListener/Doctrine/DiscriminatorMapListener.php
new file mode 100644
index 0000000..bb39187
--- /dev/null
+++ b/src/EventListener/Doctrine/DiscriminatorMapListener.php
@@ -0,0 +1,54 @@
+ $resources the
+ * %sylius.resources% parameter
+ */
+ public function __construct(
+ private readonly string $transactionClass,
+ private readonly array $resources,
+ ) {
+ }
+
+ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void
+ {
+ $metadata = $eventArgs->getClassMetadata();
+
+ if ($metadata->getName() !== $this->transactionClass) {
+ return;
+ }
+
+ foreach ($this->resources as $resource) {
+ $model = $resource['classes']['model'] ?? null;
+ if (null === $model || !is_subclass_of($model, $this->transactionClass)) {
+ continue;
+ }
+
+ $reflection = new \ReflectionClass($model);
+ if ($reflection->isAbstract() || in_array($model, $metadata->discriminatorMap, true)) {
+ continue;
+ }
+
+ /** @var callable(): string $discriminator */
+ $discriminator = [$model, 'getDiscriminator'];
+
+ $metadata->addDiscriminatorMapClass($discriminator(), $model);
+ }
+ }
+}
diff --git a/src/EventListener/EarningTriggerListener.php b/src/EventListener/EarningTriggerListener.php
new file mode 100644
index 0000000..0581a00
--- /dev/null
+++ b/src/EventListener/EarningTriggerListener.php
@@ -0,0 +1,25 @@
+processor->process($event);
+ }
+}
diff --git a/src/EventListener/RedeemPointsListener.php b/src/EventListener/RedeemPointsListener.php
new file mode 100644
index 0000000..f81398f
--- /dev/null
+++ b/src/EventListener/RedeemPointsListener.php
@@ -0,0 +1,51 @@
+appliedPointsProvider->getAppliedPoints($order);
+ if ($appliedPoints <= 0) {
+ return;
+ }
+
+ // Balance and enabled state are re-validated inside the account lock
+ $this->ledger->redeem($order, $appliedPoints);
+ }
+
+ /**
+ * The symfony/workflow entry point.
+ */
+ public function onWorkflowTransition(TransitionEvent $event): void
+ {
+ $order = $event->getSubject();
+ if ($order instanceof OrderInterface) {
+ $this->redeem($order);
+ }
+ }
+}
diff --git a/src/EventListener/ReferralQueryParameterListener.php b/src/EventListener/ReferralQueryParameterListener.php
new file mode 100644
index 0000000..712d5cc
--- /dev/null
+++ b/src/EventListener/ReferralQueryParameterListener.php
@@ -0,0 +1,63 @@
+ $accountRepository
+ */
+ public function __construct(
+ private readonly RepositoryInterface $accountRepository,
+ private readonly string $queryParameter,
+ ) {
+ }
+
+ public function onRequest(RequestEvent $event): void
+ {
+ if (!$event->isMainRequest()) {
+ return;
+ }
+
+ $code = $event->getRequest()->query->get($this->queryParameter);
+ if (!is_string($code)) {
+ return;
+ }
+
+ $code = strtoupper($code);
+ if (!AttributionCookie::isValidFormat($code)) {
+ return;
+ }
+
+ if (null === $this->accountRepository->findOneBy(['referralCode' => $code])) {
+ return;
+ }
+
+ $this->capturedCode = $code;
+ }
+
+ public function onResponse(ResponseEvent $event): void
+ {
+ if (null === $this->capturedCode || !$event->isMainRequest()) {
+ return;
+ }
+
+ $event->getResponse()->headers->setCookie(AttributionCookie::create($this->capturedCode));
+ $this->capturedCode = null;
+ }
+}
diff --git a/src/EventListener/RollbackRedemptionListener.php b/src/EventListener/RollbackRedemptionListener.php
new file mode 100644
index 0000000..c24081e
--- /dev/null
+++ b/src/EventListener/RollbackRedemptionListener.php
@@ -0,0 +1,41 @@
+ledger->rollbackRedeem($order);
+ }
+
+ /**
+ * The symfony/workflow entry point.
+ */
+ public function onWorkflowCompleted(CompletedEvent $event): void
+ {
+ $order = $event->getSubject();
+ if ($order instanceof OrderInterface) {
+ $this->rollback($order);
+ }
+ }
+}
diff --git a/src/EventSubscriber/AdminMenuSubscriber.php b/src/EventSubscriber/AdminMenuSubscriber.php
new file mode 100644
index 0000000..0d57704
--- /dev/null
+++ b/src/EventSubscriber/AdminMenuSubscriber.php
@@ -0,0 +1,36 @@
+ 'addLoyaltyMenuItem',
+ ];
+ }
+
+ public function addLoyaltyMenuItem(MenuBuilderEvent $event): void
+ {
+ $marketing = $event->getMenu()->getChild('marketing');
+ if (null === $marketing) {
+ return;
+ }
+
+ $marketing
+ ->addChild('setono_sylius_loyalty', ['route' => 'setono_sylius_loyalty_admin_dashboard'])
+ ->setLabel('setono_sylius_loyalty.ui.loyalty')
+ ->setLabelAttribute('icon', 'star')
+ ;
+ }
+}
diff --git a/src/EventSubscriber/ShopAccountMenuSubscriber.php b/src/EventSubscriber/ShopAccountMenuSubscriber.php
new file mode 100644
index 0000000..f8f48f3
--- /dev/null
+++ b/src/EventSubscriber/ShopAccountMenuSubscriber.php
@@ -0,0 +1,27 @@
+ 'addLoyaltyMenuItem',
+ ];
+ }
+
+ public function addLoyaltyMenuItem(MenuBuilderEvent $event): void
+ {
+ $event->getMenu()
+ ->addChild('setono_sylius_loyalty', ['route' => 'setono_sylius_loyalty_shop_account_loyalty'])
+ ->setLabel('setono_sylius_loyalty.ui.my_loyalty')
+ ->setLabelAttribute('icon', 'star')
+ ;
+ }
+}
diff --git a/src/Exception/AccountNotFoundException.php b/src/Exception/AccountNotFoundException.php
new file mode 100644
index 0000000..e493bf6
--- /dev/null
+++ b/src/Exception/AccountNotFoundException.php
@@ -0,0 +1,9 @@
+getId() ?? 'unsaved'),
+ $account->getBalance(),
+ $points,
+ ));
+ }
+}
diff --git a/src/Exception/InvalidExpressionException.php b/src/Exception/InvalidExpressionException.php
new file mode 100644
index 0000000..bce387c
--- /dev/null
+++ b/src/Exception/InvalidExpressionException.php
@@ -0,0 +1,13 @@
+ $available
+ */
+ public static function unknown(string $code, array $available): self
+ {
+ return new self(sprintf(
+ 'No tier qualification basis registered with code "%s" (available: %s)',
+ $code,
+ implode(', ', $available),
+ ));
+ }
+}
diff --git a/src/Exception/InvalidTriggerException.php b/src/Exception/InvalidTriggerException.php
new file mode 100644
index 0000000..d1ac644
--- /dev/null
+++ b/src/Exception/InvalidTriggerException.php
@@ -0,0 +1,13 @@
+object, $method)) {
+ $value = (new \ReflectionMethod($this->object, $method))->invoke($this->object);
+
+ return is_object($value) && !$value instanceof \DateTimeInterface ? new self($value) : $value;
+ }
+ }
+
+ $properties = get_object_vars($this->object);
+ if (array_key_exists($name, $properties)) {
+ return $properties[$name];
+ }
+
+ throw new \RuntimeException(sprintf('Neither a getter nor a public property "%s" exists on "%s".', $name, $this->object::class));
+ }
+
+ public function __isset(string $name): bool
+ {
+ foreach (['get', 'is', 'has'] as $prefix) {
+ if (method_exists($this->object, $prefix . ucfirst($name))) {
+ return true;
+ }
+ }
+
+ return property_exists($this->object, $name);
+ }
+}
diff --git a/src/Expression/ExpressionCatalog.php b/src/Expression/ExpressionCatalog.php
new file mode 100644
index 0000000..45b5aa6
--- /dev/null
+++ b/src/Expression/ExpressionCatalog.php
@@ -0,0 +1,121 @@
+|null}>
+ */
+ private const VARIABLES = [
+ 'order' => [
+ 'type' => 'order',
+ 'description' => 'setono_sylius_loyalty.expression.variable.order',
+ 'triggers' => [EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE],
+ ],
+ 'basis' => [
+ 'type' => 'int',
+ 'description' => 'setono_sylius_loyalty.expression.variable.basis',
+ 'triggers' => [EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE],
+ ],
+ 'customer' => [
+ 'type' => 'customer',
+ 'description' => 'setono_sylius_loyalty.expression.variable.customer',
+ 'triggers' => null,
+ ],
+ 'account' => [
+ 'type' => 'account',
+ 'description' => 'setono_sylius_loyalty.expression.variable.account',
+ 'triggers' => null,
+ ],
+ 'channel' => [
+ 'type' => 'channel',
+ 'description' => 'setono_sylius_loyalty.expression.variable.channel',
+ 'triggers' => null,
+ ],
+ 'context' => [
+ 'type' => 'context',
+ 'description' => 'setono_sylius_loyalty.expression.variable.context',
+ 'triggers' => null,
+ ],
+ ];
+
+ /**
+ * @var array>
+ */
+ private const TYPES = [
+ 'order' => [
+ 'total' => 'int',
+ 'itemsTotal' => 'int',
+ 'shippingTotal' => 'int',
+ 'number' => 'string',
+ 'currencyCode' => 'string',
+ 'customer' => 'customer',
+ 'channel' => 'channel',
+ ],
+ 'customer' => [
+ 'email' => 'string',
+ 'firstName' => 'string',
+ 'lastName' => 'string',
+ 'group' => 'customer_group',
+ ],
+ 'customer_group' => [
+ 'code' => 'string',
+ 'name' => 'string',
+ ],
+ 'account' => [
+ 'balance' => 'int',
+ 'lifetimeEarned' => 'int',
+ 'enabled' => 'bool',
+ ],
+ 'channel' => [
+ 'code' => 'string',
+ 'name' => 'string',
+ ],
+ ];
+
+ public function __construct(
+ private readonly ExpressionFunctionRegistryInterface $functions,
+ ) {
+ }
+
+ public function getVariables(?string $trigger = null): array
+ {
+ if (null === $trigger) {
+ return self::VARIABLES;
+ }
+
+ return array_filter(
+ self::VARIABLES,
+ static fn (array $variable): bool => null === $variable['triggers'] || in_array($trigger, $variable['triggers'], true),
+ );
+ }
+
+ public function getTypeMembers(string $type): array
+ {
+ return self::TYPES[$type] ?? [];
+ }
+
+ public function toArray(): array
+ {
+ $functions = [];
+ foreach ($this->functions->all() as $function) {
+ $functions[] = [
+ 'name' => $function->getName(),
+ 'signature' => $function->getSignature(),
+ 'description' => $function->getDescription(),
+ ];
+ }
+
+ return [
+ 'variables' => self::VARIABLES,
+ 'types' => self::TYPES,
+ 'functions' => $functions,
+ ];
+ }
+}
diff --git a/src/Expression/ExpressionCatalogInterface.php b/src/Expression/ExpressionCatalogInterface.php
new file mode 100644
index 0000000..fa10e25
--- /dev/null
+++ b/src/Expression/ExpressionCatalogInterface.php
@@ -0,0 +1,37 @@
+|null}>
+ */
+ public function getVariables(?string $trigger = null): array;
+
+ /**
+ * The members of a catalog type: member name => type name (a scalar type or another
+ * catalog type).
+ *
+ * @return array
+ */
+ public function getTypeMembers(string $type): array;
+
+ /**
+ * The serializable form consumed by the expression editor.
+ *
+ * @return array
+ */
+ public function toArray(): array;
+}
diff --git a/src/Expression/ExpressionEvaluator.php b/src/Expression/ExpressionEvaluator.php
new file mode 100644
index 0000000..ed5af01
--- /dev/null
+++ b/src/Expression/ExpressionEvaluator.php
@@ -0,0 +1,82 @@
+validator->validate($expression);
+
+ try {
+ // Expressions evaluate against the entities directly (rules are authored by
+ // administrators) through the generic getter bridge; the catalog whitelist is
+ // enforced at save and lint time by the validator
+ return $this->expressionLanguage()->evaluate($expression, [
+ self::CONTEXT_VARIABLE => $context,
+ 'order' => null === $context->order ? null : new EntityAccess($context->order),
+ 'basis' => $basisOverride ?? $context->getBasis(),
+ 'customer' => null === $context->customer ? null : new EntityAccess($context->customer),
+ 'account' => null === $context->account ? null : new EntityAccess($context->account),
+ 'channel' => new EntityAccess($context->channel),
+ 'context' => (object) $context->context,
+ ]);
+ } catch (SyntaxError $e) {
+ throw new InvalidExpressionException($e->getMessage(), 0, $e);
+ }
+ }
+
+ private function expressionLanguage(): ExpressionLanguage
+ {
+ if (null === $this->expressionLanguage) {
+ $expressionLanguage = new ExpressionLanguage();
+
+ foreach ($this->functions->all() as $function) {
+ $this->register($expressionLanguage, $function);
+ }
+
+ $this->expressionLanguage = $expressionLanguage;
+ }
+
+ return $this->expressionLanguage;
+ }
+
+ private function register(ExpressionLanguage $expressionLanguage, ExpressionFunctionInterface $function): void
+ {
+ $expressionLanguage->register(
+ $function->getName(),
+ static function (): string {
+ throw new \LogicException('Loyalty expressions are never compiled');
+ },
+ static function (array $variables, mixed ...$arguments) use ($function): mixed {
+ $context = $variables[self::CONTEXT_VARIABLE];
+ \assert($context instanceof EarningContext);
+
+ return $function->evaluate($context, ...$arguments);
+ },
+ );
+ }
+}
diff --git a/src/Expression/ExpressionEvaluatorInterface.php b/src/Expression/ExpressionEvaluatorInterface.php
new file mode 100644
index 0000000..54e8eea
--- /dev/null
+++ b/src/Expression/ExpressionEvaluatorInterface.php
@@ -0,0 +1,22 @@
+catalog->getVariables($trigger);
+
+ try {
+ $parsed = $this->expressionLanguage()->parse($expression, array_keys($variables));
+ } catch (SyntaxError $e) {
+ throw new InvalidExpressionException($e->getMessage(), 0, $e);
+ }
+
+ $this->walk($parsed->getNodes(), $variables);
+ }
+
+ /**
+ * @param array|null}> $variables
+ */
+ private function walk(Node $node, array $variables): void
+ {
+ if ($node instanceof GetAttrNode) {
+ $this->validateAttributeAccess($node, $variables);
+ }
+
+ foreach (self::children($node) as $child) {
+ $this->walk($child, $variables);
+ }
+ }
+
+ /**
+ * @param array|null}> $variables
+ */
+ private function validateAttributeAccess(GetAttrNode $node, array $variables): void
+ {
+ $type = self::attribute($node, 'type');
+
+ if (GetAttrNode::METHOD_CALL === $type) {
+ throw new InvalidExpressionException('Method calls are not available in expressions');
+ }
+
+ if (GetAttrNode::ARRAY_CALL === $type) {
+ throw new InvalidExpressionException('Array access is not available in expressions; use dotted property access');
+ }
+
+ // Resolving the type validates the whole chain
+ $this->resolveType($node, $variables);
+ }
+
+ /**
+ * Resolves the catalog type of a node in a dotted chain. Returns null for the dynamic
+ * trigger context, whose members are only known at dispatch time.
+ *
+ * @param array|null}> $variables
+ */
+ private function resolveType(Node $node, array $variables): ?string
+ {
+ if ($node instanceof NameNode) {
+ $name = self::attribute($node, 'name');
+ if (!is_string($name) || !isset($variables[$name])) {
+ throw new InvalidExpressionException(sprintf('Unknown variable "%s"', is_string($name) ? $name : ''));
+ }
+
+ return $variables[$name]['type'];
+ }
+
+ if ($node instanceof GetAttrNode) {
+ $parent = self::children($node)['node'] ?? null;
+ if (null === $parent) {
+ throw new InvalidExpressionException('Property access is only available on catalog variables');
+ }
+
+ $parentType = $this->resolveType($parent, $variables);
+ if (null === $parentType) {
+ // Dynamic context members cannot be checked statically
+ return null;
+ }
+
+ $attributeNode = self::children($node)['attribute'] ?? null;
+ $attribute = $attributeNode instanceof ConstantNode ? self::attribute($attributeNode, 'value') : null;
+ if (!is_string($attribute)) {
+ throw new InvalidExpressionException('Dynamic property access is not available in expressions');
+ }
+
+ if ('context' === $parentType) {
+ return null;
+ }
+
+ if (in_array($parentType, self::SCALAR_TYPES, true)) {
+ throw new InvalidExpressionException(sprintf('Cannot access "%s" on a %s value', $attribute, $parentType));
+ }
+
+ $members = $this->catalog->getTypeMembers($parentType);
+ if (!isset($members[$attribute])) {
+ throw new InvalidExpressionException(sprintf('"%s" is not available on "%s"', $attribute, $parentType));
+ }
+
+ return $members[$attribute];
+ }
+
+ throw new InvalidExpressionException('Property access is only available on catalog variables');
+ }
+
+ /**
+ * The Node base class declares its properties without types, so accesses are funneled
+ * through these helpers.
+ *
+ * @return array
+ */
+ private static function children(Node $node): array
+ {
+ /** @var mixed $children */
+ $children = $node->nodes;
+ if (!is_array($children)) {
+ return [];
+ }
+
+ return array_filter($children, static fn (mixed $child): bool => $child instanceof Node);
+ }
+
+ private static function attribute(Node $node, string $key): mixed
+ {
+ /** @var mixed $attributes */
+ $attributes = $node->attributes;
+ if (!is_array($attributes)) {
+ return null;
+ }
+
+ return $attributes[$key] ?? null;
+ }
+
+ private function expressionLanguage(): ExpressionLanguage
+ {
+ if (null === $this->expressionLanguage) {
+ $this->expressionLanguage = new ExpressionLanguage();
+
+ foreach ($this->functions->all() as $function) {
+ $this->expressionLanguage->register(
+ $function->getName(),
+ static fn (): string => '',
+ static fn (): mixed => null,
+ );
+ }
+ }
+
+ return $this->expressionLanguage;
+ }
+}
diff --git a/src/Expression/ExpressionValidatorInterface.php b/src/Expression/ExpressionValidatorInterface.php
new file mode 100644
index 0000000..8cdb2fd
--- /dev/null
+++ b/src/Expression/ExpressionValidatorInterface.php
@@ -0,0 +1,22 @@
+getNow()->format('N');
+ }
+}
diff --git a/src/Expression/Function/ExpressionFunctionInterface.php b/src/Expression/Function/ExpressionFunctionInterface.php
new file mode 100644
index 0000000..326febd
--- /dev/null
+++ b/src/Expression/Function/ExpressionFunctionInterface.php
@@ -0,0 +1,30 @@
+
+ */
+final class ExpressionFunctionRegistry extends CompositeService implements ExpressionFunctionRegistryInterface
+{
+ public function get(string $name): ?ExpressionFunctionInterface
+ {
+ foreach ($this->services as $function) {
+ if ($function->getName() === $name) {
+ return $function;
+ }
+ }
+
+ return null;
+ }
+
+ public function all(): array
+ {
+ return $this->services;
+ }
+}
diff --git a/src/Expression/Function/ExpressionFunctionRegistryInterface.php b/src/Expression/Function/ExpressionFunctionRegistryInterface.php
new file mode 100644
index 0000000..8098bb2
--- /dev/null
+++ b/src/Expression/Function/ExpressionFunctionRegistryInterface.php
@@ -0,0 +1,15 @@
+
+ */
+ public function all(): array;
+}
diff --git a/src/Expression/Function/IsFirstOrderFunction.php b/src/Expression/Function/IsFirstOrderFunction.php
new file mode 100644
index 0000000..b9cb9ee
--- /dev/null
+++ b/src/Expression/Function/IsFirstOrderFunction.php
@@ -0,0 +1,51 @@
+customer) {
+ return false;
+ }
+
+ $count = $this->ordersCount->evaluate($context);
+ $paidOrders = is_int($count) ? $count : 0;
+
+ // When the evaluated order is not itself counted as paid yet, it is counted on top
+ if (null !== $context->order && OrderPaymentStates::STATE_PAID !== $context->order->getPaymentState()) {
+ ++$paidOrders;
+ }
+
+ return 1 === $paidOrders;
+ }
+}
diff --git a/src/Expression/Function/ItemsOfTaxonFunction.php b/src/Expression/Function/ItemsOfTaxonFunction.php
new file mode 100644
index 0000000..d203971
--- /dev/null
+++ b/src/Expression/Function/ItemsOfTaxonFunction.php
@@ -0,0 +1,63 @@
+order) {
+ return 0;
+ }
+
+ $units = 0;
+ foreach ($context->order->getItems() as $item) {
+ if ($item instanceof OrderItemInterface && self::hasTaxon($item, $taxonCode)) {
+ $units += $item->getQuantity();
+ }
+ }
+
+ return $units;
+ }
+
+ private static function hasTaxon(OrderItemInterface $item, string $taxonCode): bool
+ {
+ $product = $item->getProduct();
+ if (!$product instanceof ProductInterface) {
+ return false;
+ }
+
+ foreach ($product->getTaxons() as $taxon) {
+ if ($taxon->getCode() === $taxonCode) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Expression/Function/MathFunction.php b/src/Expression/Function/MathFunction.php
new file mode 100644
index 0000000..4ee8002
--- /dev/null
+++ b/src/Expression/Function/MathFunction.php
@@ -0,0 +1,62 @@
+name;
+ }
+
+ public function getSignature(): string
+ {
+ return match ($this->name) {
+ 'min', 'max' => sprintf('%s(...values: number): number', $this->name),
+ default => sprintf('%s(value: number): number', $this->name),
+ };
+ }
+
+ public function getDescription(): string
+ {
+ return sprintf('setono_sylius_loyalty.expression.function.%s', $this->name);
+ }
+
+ public function evaluate(EarningContext $context, mixed ...$arguments): mixed
+ {
+ $numbers = array_map(
+ static fn (mixed $argument): float => is_numeric($argument) ? (float) $argument : 0.0,
+ array_values($arguments),
+ );
+
+ if ([] === $numbers) {
+ return 0.0;
+ }
+
+ return match ($this->name) {
+ 'floor' => floor($numbers[0]),
+ 'ceil' => ceil($numbers[0]),
+ 'round' => round($numbers[0]),
+ 'abs' => abs($numbers[0]),
+ 'min' => min($numbers),
+ 'max' => max($numbers),
+ default => 0.0,
+ };
+ }
+}
diff --git a/src/Expression/Function/OrdersCountFunction.php b/src/Expression/Function/OrdersCountFunction.php
new file mode 100644
index 0000000..93acff9
--- /dev/null
+++ b/src/Expression/Function/OrdersCountFunction.php
@@ -0,0 +1,59 @@
+customer) {
+ return 0;
+ }
+
+ return (int) $this->entityManager->createQueryBuilder()
+ ->select('COUNT(o.id)')
+ ->from($this->orderClass, 'o')
+ ->andWhere('o.customer = :customer')
+ ->andWhere('o.channel = :channel')
+ ->andWhere('o.paymentState = :paymentState')
+ ->setParameter('customer', $context->customer)
+ ->setParameter('channel', $context->channel)
+ ->setParameter('paymentState', OrderPaymentStates::STATE_PAID)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+ }
+}
diff --git a/src/Expression/Function/ProductTotalFunction.php b/src/Expression/Function/ProductTotalFunction.php
new file mode 100644
index 0000000..aa349a8
--- /dev/null
+++ b/src/Expression/Function/ProductTotalFunction.php
@@ -0,0 +1,46 @@
+order) {
+ return 0;
+ }
+
+ $total = 0;
+ foreach ($context->order->getItems() as $item) {
+ if ($item instanceof OrderItemInterface && $item->getProduct()?->getCode() === $productCode) {
+ $total += $context->itemAmounts[(int) $item->getId()] ?? 0;
+ }
+ }
+
+ return $total;
+ }
+}
diff --git a/src/Expression/Function/TaxonTotalFunction.php b/src/Expression/Function/TaxonTotalFunction.php
new file mode 100644
index 0000000..a4c43e3
--- /dev/null
+++ b/src/Expression/Function/TaxonTotalFunction.php
@@ -0,0 +1,63 @@
+order) {
+ return 0;
+ }
+
+ $total = 0;
+ foreach ($context->order->getItems() as $item) {
+ if ($item instanceof OrderItemInterface && self::hasTaxon($item, $taxonCode)) {
+ $total += $context->itemAmounts[(int) $item->getId()] ?? 0;
+ }
+ }
+
+ return $total;
+ }
+
+ private static function hasTaxon(OrderItemInterface $item, string $taxonCode): bool
+ {
+ $product = $item->getProduct();
+ if (!$product instanceof ProductInterface) {
+ return false;
+ }
+
+ foreach ($product->getTaxons() as $taxon) {
+ if ($taxon->getCode() === $taxonCode) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Fixture/EarningRuleFixture.php b/src/Fixture/EarningRuleFixture.php
new file mode 100644
index 0000000..e72a8d9
--- /dev/null
+++ b/src/Fixture/EarningRuleFixture.php
@@ -0,0 +1,37 @@
+children()
+ ->scalarNode('name')->cannotBeEmpty()->end()
+ ->scalarNode('channel')->cannotBeEmpty()->end()
+ ->scalarNode('trigger')->end()
+ ->scalarNode('scope')->end()
+ ->variableNode('scope_configuration')->end()
+ ->scalarNode('conditions_match')->end()
+ ->variableNode('conditions')->end()
+ ->scalarNode('amount_type')->cannotBeEmpty()->end()
+ ->variableNode('amount_configuration')->end()
+ ->integerNode('priority')->end()
+ ->booleanNode('stackable')->end()
+ ->booleanNode('enabled')->end()
+ ->booleanNode('dry_run')->end()
+ ->end()
+ ;
+ }
+}
diff --git a/src/Fixture/Factory/EarningRuleExampleFactory.php b/src/Fixture/Factory/EarningRuleExampleFactory.php
new file mode 100644
index 0000000..697fa47
--- /dev/null
+++ b/src/Fixture/Factory/EarningRuleExampleFactory.php
@@ -0,0 +1,94 @@
+ $ruleFactory
+ * @param FactoryInterface $conditionFactory
+ * @param ChannelRepositoryInterface $channelRepository
+ */
+ public function __construct(
+ private readonly FactoryInterface $ruleFactory,
+ private readonly FactoryInterface $conditionFactory,
+ private readonly ChannelRepositoryInterface $channelRepository,
+ ) {
+ $this->optionsResolver = new OptionsResolver();
+
+ $this->optionsResolver
+ ->setRequired('name')
+ ->setAllowedTypes('name', 'string')
+ ->setRequired('channel')
+ ->setAllowedTypes('channel', 'string')
+ ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface {
+ $channel = $this->channelRepository->findOneByCode($code);
+ Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code));
+
+ return $channel;
+ })
+ ->setDefault('trigger', EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE)
+ ->setDefault('scope', EarningRuleInterface::SCOPE_ORDER)
+ ->setDefault('scope_configuration', [])
+ ->setDefault('conditions_match', EarningRuleInterface::CONDITIONS_MATCH_ALL)
+ ->setDefault('conditions', [])
+ ->setAllowedTypes('conditions', 'array')
+ ->setRequired('amount_type')
+ ->setAllowedTypes('amount_type', 'string')
+ ->setDefault('amount_configuration', [])
+ ->setDefault('priority', 0)
+ ->setDefault('stackable', true)
+ ->setDefault('enabled', true)
+ ->setDefault('dry_run', false)
+ ;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function create(array $options = []): EarningRuleInterface
+ {
+ /** @var array{name: string, channel: ChannelInterface, trigger: string, scope: string, scope_configuration: array, conditions_match: string, conditions: list}>, amount_type: string, amount_configuration: array, priority: int, stackable: bool, enabled: bool, dry_run: bool} $options */
+ $options = $this->optionsResolver->resolve($options);
+
+ $rule = $this->ruleFactory->createNew();
+ $rule->setName($options['name']);
+ $rule->setChannel($options['channel']);
+ $rule->setTrigger($options['trigger']);
+ $rule->setScope($options['scope']);
+ $rule->setScopeConfiguration($options['scope_configuration']);
+ $rule->setConditionsMatch($options['conditions_match']);
+ $rule->setAmountType($options['amount_type']);
+ $rule->setAmountConfiguration($options['amount_configuration']);
+ $rule->setPriority($options['priority']);
+ $rule->setStackable($options['stackable']);
+ $rule->setEnabled($options['enabled']);
+ $rule->setDryRun($options['dry_run']);
+
+ foreach ($options['conditions'] as $conditionOptions) {
+ $condition = $this->conditionFactory->createNew();
+ $condition->setType($conditionOptions['type']);
+ $condition->setConfiguration($conditionOptions['configuration'] ?? []);
+ $rule->addCondition($condition);
+ }
+
+ return $rule;
+ }
+}
diff --git a/src/Fixture/Factory/LoyaltyAccountExampleFactory.php b/src/Fixture/Factory/LoyaltyAccountExampleFactory.php
new file mode 100644
index 0000000..98635e9
--- /dev/null
+++ b/src/Fixture/Factory/LoyaltyAccountExampleFactory.php
@@ -0,0 +1,176 @@
+ $customerFactory
+ * @param FactoryInterface $shopUserFactory
+ * @param UserRepositoryInterface $shopUserRepository
+ * @param ChannelRepositoryInterface $channelRepository
+ */
+ public function __construct(
+ private readonly LoyaltyAccountProviderInterface $accountProvider,
+ private readonly LoyaltyLedgerInterface $ledger,
+ private readonly FactoryInterface $customerFactory,
+ private readonly FactoryInterface $shopUserFactory,
+ private readonly UserRepositoryInterface $shopUserRepository,
+ private readonly ChannelRepositoryInterface $channelRepository,
+ private readonly ObjectManager $objectManager,
+ ) {
+ $this->optionsResolver = new OptionsResolver();
+
+ $this->optionsResolver
+ ->setRequired('email')
+ ->setAllowedTypes('email', 'string')
+ ->setRequired('channel')
+ ->setAllowedTypes('channel', 'string')
+ ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface {
+ $channel = $this->channelRepository->findOneByCode($code);
+ Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code));
+
+ return $channel;
+ })
+ ->setDefault('password', 'sylius')
+ ->setDefault('enabled', true)
+ ->setDefault('history', [])
+ ->setAllowedTypes('history', 'array')
+ ;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function create(array $options = []): LoyaltyAccountInterface
+ {
+ /** @var array{email: string, channel: ChannelInterface, password: string, enabled: bool, history: list>} $options */
+ $options = $this->optionsResolver->resolve($options);
+
+ $account = $this->accountProvider->getByCustomerAndChannel(
+ $this->customer($options['email'], $options['password']),
+ $options['channel'],
+ );
+
+ foreach ($options['history'] as $entry) {
+ $this->writeHistoryEntry($account, $entry);
+ }
+
+ // Disabling happens after the history is written, so disabled sample accounts can
+ // still carry a balance
+ $account->setEnabled($options['enabled']);
+
+ return $account;
+ }
+
+ /**
+ * @param array $entry
+ */
+ private function writeHistoryEntry(LoyaltyAccountInterface $account, array $entry): void
+ {
+ $type = $entry['type'] ?? null;
+ Assert::string($type, 'Every history entry needs a "type"');
+
+ $points = $entry['points'] ?? null;
+ Assert::integer($points, 'Every history entry needs integer "points"');
+
+ $expiresAt = null;
+ if (isset($entry['expires_at'])) {
+ Assert::string($entry['expires_at']);
+ $expiresAt = new \DateTimeImmutable($entry['expires_at']);
+ }
+
+ switch ($type) {
+ case 'earn_action':
+ $sourceIdentifier = $entry['source_identifier'] ?? null;
+ Assert::string($sourceIdentifier, 'earn_action history entries need a "source_identifier"');
+ $this->ledger->earnAction($account, $points, $sourceIdentifier, [], $expiresAt);
+
+ break;
+ case 'manual_credit':
+ $this->ledger->manualCredit($account, $points, self::reason($entry), self::note($entry));
+
+ break;
+ case 'manual_debit':
+ $this->ledger->manualDebit($account, $points, self::reason($entry), self::note($entry));
+
+ break;
+ default:
+ throw new \InvalidArgumentException(sprintf(
+ 'Unsupported history entry type "%s" (supported: earn_action, manual_credit, manual_debit)',
+ $type,
+ ));
+ }
+ }
+
+ private function customer(string $email, string $password): CustomerInterface
+ {
+ $existing = $this->shopUserRepository->findOneByEmail($email);
+ if ($existing instanceof ShopUserInterface && $existing->getCustomer() instanceof CustomerInterface) {
+ return $existing->getCustomer();
+ }
+
+ $customer = $this->customerFactory->createNew();
+ $customer->setEmail($email);
+ $customer->setFirstName('Loyal');
+ $customer->setLastName('Customer');
+
+ $shopUser = $this->shopUserFactory->createNew();
+ $shopUser->setCustomer($customer);
+ $shopUser->setUsername($email);
+ $shopUser->setPlainPassword($password);
+ $shopUser->setEnabled(true);
+
+ $this->objectManager->persist($customer);
+ $this->objectManager->persist($shopUser);
+ $this->objectManager->flush();
+
+ return $customer;
+ }
+
+ /**
+ * @param array $entry
+ */
+ private static function reason(array $entry): string
+ {
+ $reason = $entry['reason'] ?? 'goodwill';
+ Assert::string($reason);
+
+ return $reason;
+ }
+
+ /**
+ * @param array $entry
+ */
+ private static function note(array $entry): string
+ {
+ $note = $entry['note'] ?? 'Fixture history entry';
+ Assert::string($note);
+
+ return $note;
+ }
+}
diff --git a/src/Fixture/Factory/LoyaltyProgramExampleFactory.php b/src/Fixture/Factory/LoyaltyProgramExampleFactory.php
new file mode 100644
index 0000000..ef35437
--- /dev/null
+++ b/src/Fixture/Factory/LoyaltyProgramExampleFactory.php
@@ -0,0 +1,79 @@
+ $channelRepository
+ */
+ public function __construct(
+ private readonly LoyaltyProgramProviderInterface $programProvider,
+ private readonly ChannelRepositoryInterface $channelRepository,
+ ) {
+ $this->optionsResolver = new OptionsResolver();
+
+ $this->optionsResolver
+ ->setRequired('channel')
+ ->setAllowedTypes('channel', 'string')
+ ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface {
+ $channel = $this->channelRepository->findOneByCode($code);
+ Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code));
+
+ return $channel;
+ })
+ ->setDefault('award_order_points_at', LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_PAYMENT_PAID)
+ ->setDefault('earning_basis', LoyaltyProgramInterface::EARNING_BASIS_ITEMS_TOTAL)
+ ->setDefault('include_taxes', false)
+ ->setDefault('rounding', LoyaltyProgramInterface::ROUNDING_FLOOR)
+ ->setDefault('redemption_conversion_points', 1)
+ ->setDefault('redemption_conversion_amount', 1)
+ ->setDefault('min_redeem_points', 500)
+ ->setDefault('max_redeem_percent_of_order', 50)
+ ->setDefault('points_expiry_days', 365)
+ ->setAllowedTypes('points_expiry_days', ['int', 'null'])
+ ->setDefault('clawback_policy', LoyaltyProgramInterface::CLAWBACK_POLICY_ALLOW_NEGATIVE)
+ ->setDefault('retroactive_guest_points', false)
+ ;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function create(array $options = []): LoyaltyProgramInterface
+ {
+ /** @var array{channel: ChannelInterface, award_order_points_at: string, earning_basis: string, include_taxes: bool, rounding: string, redemption_conversion_points: int, redemption_conversion_amount: int, min_redeem_points: int, max_redeem_percent_of_order: int, points_expiry_days: int|null, clawback_policy: string, retroactive_guest_points: bool} $options */
+ $options = $this->optionsResolver->resolve($options);
+
+ $program = $this->programProvider->getByChannel($options['channel']);
+ $program->setAwardOrderPointsAt($options['award_order_points_at']);
+ $program->setEarningBasis($options['earning_basis']);
+ $program->setIncludeTaxes($options['include_taxes']);
+ $program->setRounding($options['rounding']);
+ $program->setRedemptionConversionPoints($options['redemption_conversion_points']);
+ $program->setRedemptionConversionAmount($options['redemption_conversion_amount']);
+ $program->setMinRedeemPoints($options['min_redeem_points']);
+ $program->setMaxRedeemPercentOfOrder($options['max_redeem_percent_of_order']);
+ $program->setPointsExpiryDays($options['points_expiry_days']);
+ $program->setClawbackPolicy($options['clawback_policy']);
+ $program->setRetroactiveGuestPoints($options['retroactive_guest_points']);
+
+ return $program;
+ }
+}
diff --git a/src/Fixture/Factory/TierExampleFactory.php b/src/Fixture/Factory/TierExampleFactory.php
new file mode 100644
index 0000000..9de5927
--- /dev/null
+++ b/src/Fixture/Factory/TierExampleFactory.php
@@ -0,0 +1,82 @@
+ $tierFactory
+ * @param ChannelRepositoryInterface $channelRepository
+ */
+ public function __construct(
+ private readonly FactoryInterface $tierFactory,
+ private readonly ChannelRepositoryInterface $channelRepository,
+ ) {
+ $this->optionsResolver = new OptionsResolver();
+
+ $this->optionsResolver
+ ->setRequired('code')
+ ->setAllowedTypes('code', 'string')
+ ->setRequired('name')
+ ->setAllowedTypes('name', 'string')
+ ->setRequired('channel')
+ ->setAllowedTypes('channel', 'string')
+ ->setNormalizer('channel', function (Options $options, string $code): ChannelInterface {
+ $channel = $this->channelRepository->findOneByCode($code);
+ Assert::isInstanceOf($channel, ChannelInterface::class, sprintf('Channel "%s" not found', $code));
+
+ return $channel;
+ })
+ ->setDefault('position', 0)
+ ->setDefault('enabled', true)
+ ->setDefault('qualification_basis', PointsEarnedBasis::CODE)
+ ->setRequired('threshold')
+ ->setAllowedTypes('threshold', 'int')
+ ->setDefault('earning_multiplier', 1.0)
+ ->setDefault('color', null)
+ ->setDefault('benefits_description', null)
+ ;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function create(array $options = []): TierInterface
+ {
+ /** @var array{code: string, name: string, channel: ChannelInterface, position: int, enabled: bool, qualification_basis: string, threshold: int, earning_multiplier: float, color: string|null, benefits_description: string|null} $options */
+ $options = $this->optionsResolver->resolve($options);
+
+ $tier = $this->tierFactory->createNew();
+ $tier->setCode($options['code']);
+ $tier->setName($options['name']);
+ $tier->setChannel($options['channel']);
+ $tier->setPosition($options['position']);
+ $tier->setEnabled($options['enabled']);
+ $tier->setQualificationBasis($options['qualification_basis']);
+ $tier->setThreshold($options['threshold']);
+ $tier->setEarningMultiplier((float) $options['earning_multiplier']);
+ $tier->setColor($options['color']);
+ $tier->setCurrentLocale('en_US');
+ $tier->setFallbackLocale('en_US');
+ $tier->setBenefitsDescription($options['benefits_description']);
+
+ return $tier;
+ }
+}
diff --git a/src/Fixture/LoyaltyAccountFixture.php b/src/Fixture/LoyaltyAccountFixture.php
new file mode 100644
index 0000000..8396d4d
--- /dev/null
+++ b/src/Fixture/LoyaltyAccountFixture.php
@@ -0,0 +1,29 @@
+children()
+ ->scalarNode('email')->cannotBeEmpty()->end()
+ ->scalarNode('channel')->cannotBeEmpty()->end()
+ ->scalarNode('password')->end()
+ ->booleanNode('enabled')->end()
+ ->variableNode('history')->end()
+ ->end()
+ ;
+ }
+}
diff --git a/src/Fixture/LoyaltyProgramFixture.php b/src/Fixture/LoyaltyProgramFixture.php
new file mode 100644
index 0000000..0f71c9d
--- /dev/null
+++ b/src/Fixture/LoyaltyProgramFixture.php
@@ -0,0 +1,36 @@
+children()
+ ->scalarNode('channel')->cannotBeEmpty()->end()
+ ->scalarNode('award_order_points_at')->end()
+ ->scalarNode('earning_basis')->end()
+ ->booleanNode('include_taxes')->end()
+ ->scalarNode('rounding')->end()
+ ->integerNode('redemption_conversion_points')->end()
+ ->integerNode('redemption_conversion_amount')->end()
+ ->integerNode('min_redeem_points')->end()
+ ->integerNode('max_redeem_percent_of_order')->end()
+ ->integerNode('points_expiry_days')->end()
+ ->scalarNode('clawback_policy')->end()
+ ->booleanNode('retroactive_guest_points')->end()
+ ->end()
+ ;
+ }
+}
diff --git a/src/Fixture/TierFixture.php b/src/Fixture/TierFixture.php
new file mode 100644
index 0000000..1599ae3
--- /dev/null
+++ b/src/Fixture/TierFixture.php
@@ -0,0 +1,34 @@
+children()
+ ->scalarNode('code')->cannotBeEmpty()->end()
+ ->scalarNode('name')->cannotBeEmpty()->end()
+ ->scalarNode('channel')->cannotBeEmpty()->end()
+ ->integerNode('position')->end()
+ ->booleanNode('enabled')->end()
+ ->scalarNode('qualification_basis')->end()
+ ->integerNode('threshold')->end()
+ ->floatNode('earning_multiplier')->end()
+ ->scalarNode('color')->end()
+ ->scalarNode('benefits_description')->end()
+ ->end()
+ ;
+ }
+}
diff --git a/src/Form/Type/EarningRuleConditionType.php b/src/Form/Type/EarningRuleConditionType.php
new file mode 100644
index 0000000..d6916b4
--- /dev/null
+++ b/src/Form/Type/EarningRuleConditionType.php
@@ -0,0 +1,112 @@
+
+ */
+final class EarningRuleConditionType extends AbstractType
+{
+ public function __construct(
+ private readonly ConditionCheckerRegistryInterface $conditionCheckers,
+ private readonly ExpressionValidatorInterface $expressionValidator,
+ private readonly string $dataClass,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $choices = [];
+ foreach ($this->conditionCheckers->all() as $checker) {
+ $choices[$checker->getLabel()] = $checker->getType();
+ }
+
+ $builder
+ ->add('type', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.condition_type',
+ 'choices' => $choices,
+ ])
+ ->add('configuration', JsonType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.condition_configuration',
+ 'required' => false,
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.condition_configuration_help',
+ ])
+ ->add('expression', ExpressionType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.condition_expression',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.condition_expression_help',
+ 'mapped' => false,
+ ])
+ ;
+
+ $builder->addEventListener(FormEvents::POST_SET_DATA, self::populateExpression(...));
+ $builder->addEventListener(FormEvents::POST_SUBMIT, $this->handleSubmittedCondition(...));
+ }
+
+ private static function populateExpression(FormEvent $event): void
+ {
+ $condition = $event->getData();
+ if (!$condition instanceof EarningRuleConditionInterface || ExpressionConditionChecker::TYPE !== $condition->getType()) {
+ return;
+ }
+
+ $expression = $condition->getConfiguration()['expression'] ?? null;
+ if (is_string($expression)) {
+ $event->getForm()->get('expression')->setData($expression);
+ }
+ }
+
+ /**
+ * Expression conditions are validated on save with the same parse + whitelist as the lint
+ * endpoint, narrowed to the rule's trigger when reachable.
+ */
+ private function handleSubmittedCondition(FormEvent $event): void
+ {
+ $condition = $event->getData();
+ if (!$condition instanceof EarningRuleConditionInterface || ExpressionConditionChecker::TYPE !== $condition->getType()) {
+ return;
+ }
+
+ $form = $event->getForm();
+
+ $expression = $form->get('expression')->getData();
+ if (!is_string($expression) || '' === trim($expression)) {
+ $form->get('expression')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.expression_required'));
+
+ return;
+ }
+
+ try {
+ $this->expressionValidator->validate($expression, $condition->getRule()?->getTrigger());
+ $condition->setConfiguration(['expression' => $expression]);
+ } catch (InvalidExpressionException $e) {
+ $form->get('expression')->addError(new FormError($e->getMessage()));
+ }
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => $this->dataClass,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_earning_rule_condition';
+ }
+}
diff --git a/src/Form/Type/EarningRuleType.php b/src/Form/Type/EarningRuleType.php
new file mode 100644
index 0000000..48010ce
--- /dev/null
+++ b/src/Form/Type/EarningRuleType.php
@@ -0,0 +1,219 @@
+
+ */
+final class EarningRuleType extends AbstractType
+{
+ /**
+ * @param array}> $triggerCatalog
+ */
+ public function __construct(
+ private readonly AmountCalculatorRegistryInterface $amountCalculators,
+ private readonly ExpressionValidatorInterface $expressionValidator,
+ private readonly array $triggerCatalog,
+ private readonly string $dataClass,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $triggerChoices = ['setono_sylius_loyalty.trigger.order_eligible' => EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE];
+ foreach ($this->triggerCatalog as $code => $trigger) {
+ $triggerChoices[$trigger['label']] = $code;
+ }
+
+ $amountChoices = [];
+ foreach ($this->amountCalculators->all() as $calculator) {
+ $amountChoices[$calculator->getLabel()] = $calculator->getType();
+ }
+
+ $builder
+ ->add('name', TextType::class, [
+ 'label' => 'sylius.ui.name',
+ ])
+ ->add('channel', ChannelChoiceType::class, [
+ 'label' => 'sylius.ui.channel',
+ ])
+ ->add('enabled', CheckboxType::class, [
+ 'label' => 'sylius.ui.enabled',
+ 'required' => false,
+ ])
+ ->add('dryRun', CheckboxType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.dry_run',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.dry_run_help',
+ 'required' => false,
+ ])
+ ->add('priority', IntegerType::class, [
+ 'label' => 'sylius.ui.priority',
+ ])
+ ->add('trigger', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.trigger',
+ 'choices' => $triggerChoices,
+ ])
+ ->add('scope', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.scope',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.scope_help',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.earning_rule.scope_order' => EarningRuleInterface::SCOPE_ORDER,
+ 'setono_sylius_loyalty.form.earning_rule.scope_taxon' => EarningRuleInterface::SCOPE_TAXON,
+ 'setono_sylius_loyalty.form.earning_rule.scope_product' => EarningRuleInterface::SCOPE_PRODUCT,
+ ],
+ ])
+ ->add('scopeConfiguration', JsonType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.scope_configuration',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.scope_configuration_help',
+ 'required' => false,
+ ])
+ ->add('conditionsMatch', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.conditions_match',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.earning_rule.conditions_match_all' => EarningRuleInterface::CONDITIONS_MATCH_ALL,
+ 'setono_sylius_loyalty.form.earning_rule.conditions_match_any' => EarningRuleInterface::CONDITIONS_MATCH_ANY,
+ ],
+ ])
+ ->add('conditions', CollectionType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.conditions',
+ 'entry_type' => EarningRuleConditionType::class,
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'by_reference' => false,
+ 'button_add_label' => 'setono_sylius_loyalty.form.earning_rule.add_condition',
+ ])
+ ->add('amountType', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.amount_type',
+ 'choices' => $amountChoices,
+ ])
+ ->add('amountConfiguration', JsonType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.amount_configuration',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.amount_configuration_help',
+ 'required' => false,
+ ])
+ ->add('amountExpression', ExpressionType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.amount_expression',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.amount_expression_help',
+ 'mapped' => false,
+ ])
+ ->add('startsAt', DateTimeType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.starts_at',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.window_timezone_help',
+ 'widget' => 'single_text',
+ 'required' => false,
+ 'input' => 'datetime_immutable',
+ ])
+ ->add('endsAt', DateTimeType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.ends_at',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.window_timezone_help',
+ 'widget' => 'single_text',
+ 'required' => false,
+ 'input' => 'datetime_immutable',
+ ])
+ ->add('stackable', CheckboxType::class, [
+ 'label' => 'setono_sylius_loyalty.form.earning_rule.stackable',
+ 'help' => 'setono_sylius_loyalty.form.earning_rule.stackable_help',
+ 'required' => false,
+ ])
+ ;
+
+ $builder->addEventListener(FormEvents::POST_SET_DATA, self::populateAmountExpression(...));
+ $builder->addEventListener(FormEvents::POST_SUBMIT, $this->handleSubmittedRule(...));
+ }
+
+ /**
+ * Expression mode stores the amount expression inside the amount configuration; the
+ * dedicated editor field is populated from it.
+ */
+ private static function populateAmountExpression(FormEvent $event): void
+ {
+ $rule = $event->getData();
+ if (!$rule instanceof EarningRuleInterface || ExpressionAmountCalculator::TYPE !== $rule->getAmountType()) {
+ return;
+ }
+
+ $expression = $rule->getAmountConfiguration()['expression'] ?? null;
+ if (is_string($expression)) {
+ $event->getForm()->get('amountExpression')->setData($expression);
+ }
+ }
+
+ /**
+ * Validates expression mode on save (same parse + whitelist as the lint endpoint, narrowed
+ * to the rule's trigger) and enforces the trigger-kind constraints: scope, per_amount, and
+ * multiplier need an order and a basis, so they are only meaningful for the built-in order
+ * trigger; multipliers are order-scoped only.
+ */
+ private function handleSubmittedRule(FormEvent $event): void
+ {
+ $rule = $event->getData();
+ if (!$rule instanceof EarningRuleInterface) {
+ return;
+ }
+
+ $form = $event->getForm();
+
+ if (ExpressionAmountCalculator::TYPE === $rule->getAmountType()) {
+ $expression = $form->get('amountExpression')->getData();
+ if (!is_string($expression) || '' === trim($expression)) {
+ $form->get('amountExpression')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.expression_required'));
+ } else {
+ try {
+ $this->expressionValidator->validate($expression, $rule->getTrigger());
+ $rule->setAmountConfiguration(['expression' => $expression]);
+ } catch (InvalidExpressionException $e) {
+ $form->get('amountExpression')->addError(new FormError($e->getMessage()));
+ }
+ }
+ }
+
+ $isOrderTrigger = EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE === $rule->getTrigger();
+
+ if (!$isOrderTrigger && EarningRuleInterface::SCOPE_ORDER !== $rule->getScope()) {
+ $form->get('scope')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.scope_requires_order_trigger'));
+ }
+
+ if (!$isOrderTrigger && in_array($rule->getAmountType(), [PerAmountCalculator::TYPE, MultiplierAmountCalculator::TYPE], true)) {
+ $form->get('amountType')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.amount_requires_order_trigger'));
+ }
+
+ if (MultiplierAmountCalculator::TYPE === $rule->getAmountType() && EarningRuleInterface::SCOPE_ORDER !== $rule->getScope()) {
+ $form->get('amountType')->addError(new FormError('setono_sylius_loyalty.form.earning_rule.multiplier_is_order_scoped'));
+ }
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => $this->dataClass,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_earning_rule';
+ }
+}
diff --git a/src/Form/Type/ExpressionType.php b/src/Form/Type/ExpressionType.php
new file mode 100644
index 0000000..26d6fa2
--- /dev/null
+++ b/src/Form/Type/ExpressionType.php
@@ -0,0 +1,73 @@
+
+ */
+final class ExpressionType extends AbstractType
+{
+ public function __construct(
+ private readonly ExpressionCatalogInterface $catalog,
+ private readonly UrlGeneratorInterface $urlGenerator,
+ private readonly string $cdnBaseUrl,
+ ) {
+ }
+
+ public function buildView(FormView $view, FormInterface $form, array $options): void
+ {
+ /** @var string|null $trigger */
+ $trigger = $options['trigger'];
+
+ /** @var array $vars */
+ $vars = $view->vars;
+
+ /** @var array $attr */
+ $attr = $vars['attr'] ?? [];
+
+ $vars['attr'] = array_merge($attr, [
+ 'data-setono-sylius-loyalty-expression' => '1',
+ 'data-catalog' => (string) json_encode($this->catalog->toArray(), \JSON_THROW_ON_ERROR),
+ 'data-lint-url' => $this->urlGenerator->generate('setono_sylius_loyalty_admin_expression_lint'),
+ 'data-cdn-base-url' => $this->cdnBaseUrl,
+ 'data-trigger' => $trigger ?? '',
+ 'rows' => 3,
+ ]);
+
+ $view->vars = $vars;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'trigger' => null,
+ 'required' => false,
+ ]);
+ $resolver->setAllowedTypes('trigger', ['string', 'null']);
+ }
+
+ public function getParent(): string
+ {
+ return TextareaType::class;
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_expression';
+ }
+}
diff --git a/src/Form/Type/JsonType.php b/src/Form/Type/JsonType.php
new file mode 100644
index 0000000..2e80625
--- /dev/null
+++ b/src/Form/Type/JsonType.php
@@ -0,0 +1,62 @@
+
+ */
+final class JsonType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->addModelTransformer(new CallbackTransformer(
+ static function (mixed $value): string {
+ if (null === $value || [] === $value) {
+ return '{}';
+ }
+
+ return (string) json_encode($value, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES);
+ },
+ static function (mixed $value): array {
+ if (!is_string($value) || '' === trim($value)) {
+ return [];
+ }
+
+ try {
+ /** @var mixed $decoded */
+ $decoded = json_decode($value, true, 32, \JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ throw new TransformationFailedException($e->getMessage(), 0, $e, 'setono_sylius_loyalty.form.invalid_json');
+ }
+
+ if (!is_array($decoded)) {
+ throw new TransformationFailedException('Expected a JSON object', 0, null, 'setono_sylius_loyalty.form.invalid_json');
+ }
+
+ return $decoded;
+ },
+ ));
+ }
+
+ public function getParent(): string
+ {
+ return TextareaType::class;
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_json';
+ }
+}
diff --git a/src/Form/Type/LoyaltyProgramType.php b/src/Form/Type/LoyaltyProgramType.php
new file mode 100644
index 0000000..b13b93c
--- /dev/null
+++ b/src/Form/Type/LoyaltyProgramType.php
@@ -0,0 +1,126 @@
+
+ */
+final class LoyaltyProgramType extends AbstractType
+{
+ public function __construct(
+ private readonly string $dataClass,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('awardOrderPointsAt', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.award_order_points_at',
+ 'help' => 'setono_sylius_loyalty.form.program.award_order_points_at_help',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.program.award_moment_payment_paid' => LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_PAYMENT_PAID,
+ 'setono_sylius_loyalty.form.program.award_moment_order_fulfilled' => LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED,
+ ],
+ ])
+ ->add('earningBasis', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.earning_basis',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.program.earning_basis_items_total' => LoyaltyProgramInterface::EARNING_BASIS_ITEMS_TOTAL,
+ 'setono_sylius_loyalty.form.program.earning_basis_order_total' => LoyaltyProgramInterface::EARNING_BASIS_ORDER_TOTAL,
+ ],
+ 'help' => 'setono_sylius_loyalty.form.program.earning_basis_help',
+ ])
+ ->add('includeTaxes', CheckboxType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.include_taxes',
+ 'required' => false,
+ ])
+ ->add('rounding', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.rounding',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.program.rounding_floor' => LoyaltyProgramInterface::ROUNDING_FLOOR,
+ 'setono_sylius_loyalty.form.program.rounding_round' => LoyaltyProgramInterface::ROUNDING_ROUND,
+ 'setono_sylius_loyalty.form.program.rounding_ceil' => LoyaltyProgramInterface::ROUNDING_CEIL,
+ ],
+ ])
+ ->add('redemptionConversionPoints', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.redemption_conversion_points',
+ 'help' => 'setono_sylius_loyalty.form.program.redemption_conversion_help',
+ ])
+ ->add('redemptionConversionAmount', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.redemption_conversion_amount',
+ 'help' => 'setono_sylius_loyalty.form.program.base_currency_help',
+ ])
+ ->add('minRedeemPoints', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.min_redeem_points',
+ ])
+ ->add('maxRedeemPercentOfOrder', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.max_redeem_percent_of_order',
+ 'help' => 'setono_sylius_loyalty.form.program.max_redeem_percent_of_order_help',
+ ])
+ ->add('pointsExpiryDays', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.points_expiry_days',
+ 'help' => 'setono_sylius_loyalty.form.program.points_expiry_days_help',
+ 'required' => false,
+ ])
+ ->add('clawbackPolicy', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.clawback_policy',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.program.clawback_policy_allow_negative' => LoyaltyProgramInterface::CLAWBACK_POLICY_ALLOW_NEGATIVE,
+ 'setono_sylius_loyalty.form.program.clawback_policy_clamp_to_zero' => LoyaltyProgramInterface::CLAWBACK_POLICY_CLAMP_TO_ZERO,
+ ],
+ ])
+ ->add('retroactiveGuestPoints', CheckboxType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.retroactive_guest_points',
+ 'help' => 'setono_sylius_loyalty.form.program.retroactive_guest_points_help',
+ 'required' => false,
+ ])
+ ->add('tierEvaluationWindow', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.tier_evaluation_window',
+ 'choices' => [
+ 'setono_sylius_loyalty.form.program.tier_window_calendar_year' => LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_CALENDAR_YEAR,
+ 'setono_sylius_loyalty.form.program.tier_window_rolling_12_months' => LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_ROLLING_12_MONTHS,
+ 'setono_sylius_loyalty.form.program.tier_window_lifetime' => LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_LIFETIME,
+ ],
+ ])
+ ->add('tierDowngradeGraceDays', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.tier_downgrade_grace_days',
+ 'help' => 'setono_sylius_loyalty.form.program.tier_downgrade_grace_days_help',
+ ])
+ ->add('showEarnableOnProduct', CheckboxType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.show_earnable_on_product',
+ 'required' => false,
+ ])
+ ->add('showEarnableInCart', CheckboxType::class, [
+ 'label' => 'setono_sylius_loyalty.form.program.show_earnable_in_cart',
+ 'required' => false,
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => $this->dataClass,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_program';
+ }
+}
diff --git a/src/Form/Type/Rule/CustomerLoyaltyTierConfigurationType.php b/src/Form/Type/Rule/CustomerLoyaltyTierConfigurationType.php
new file mode 100644
index 0000000..2353a9a
--- /dev/null
+++ b/src/Form/Type/Rule/CustomerLoyaltyTierConfigurationType.php
@@ -0,0 +1,44 @@
+
+ */
+final class CustomerLoyaltyTierConfigurationType extends AbstractType
+{
+ public function __construct(
+ private readonly TierRepositoryInterface $tierRepository,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $choices = [];
+ /** @var TierInterface $tier */
+ foreach ($this->tierRepository->findAll() as $tier) {
+ $label = sprintf('%s (%s)', (string) $tier->getName(), (string) $tier->getChannel()?->getCode());
+ $choices[$label] = $tier->getCode();
+ }
+
+ $builder->add('tier', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.promotion_rule.minimum_tier',
+ 'choices' => $choices,
+ 'constraints' => [new NotBlank(['groups' => ['sylius']])],
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_promotion_rule_customer_loyalty_tier';
+ }
+}
diff --git a/src/Form/Type/TierTranslationType.php b/src/Form/Type/TierTranslationType.php
new file mode 100644
index 0000000..4f7ed0c
--- /dev/null
+++ b/src/Form/Type/TierTranslationType.php
@@ -0,0 +1,41 @@
+
+ */
+final class TierTranslationType extends AbstractType
+{
+ public function __construct(
+ private readonly string $dataClass,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add('benefitsDescription', TextareaType::class, [
+ 'label' => 'setono_sylius_loyalty.form.tier.benefits_description',
+ 'required' => false,
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => $this->dataClass,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_tier_translation';
+ }
+}
diff --git a/src/Form/Type/TierType.php b/src/Form/Type/TierType.php
new file mode 100644
index 0000000..2129dbf
--- /dev/null
+++ b/src/Form/Type/TierType.php
@@ -0,0 +1,94 @@
+
+ */
+final class TierType extends AbstractType
+{
+ public function __construct(
+ private readonly TierQualificationBasisRegistryInterface $basisRegistry,
+ private readonly string $dataClass,
+ ) {
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $basisChoices = [];
+ $unitLabels = [];
+ foreach ($this->basisRegistry->all() as $basis) {
+ $basisChoices[$basis->getLabel()] = $basis->getCode();
+ $unitLabels[$basis->getCode()] = $basis->getUnitLabel();
+ }
+
+ $builder
+ ->add('code', TextType::class, [
+ 'label' => 'sylius.ui.code',
+ ])
+ ->add('name', TextType::class, [
+ 'label' => 'sylius.ui.name',
+ ])
+ ->add('channel', ChannelChoiceType::class, [
+ 'label' => 'sylius.ui.channel',
+ ])
+ ->add('position', IntegerType::class, [
+ 'label' => 'sylius.ui.position',
+ 'help' => 'setono_sylius_loyalty.form.tier.position_help',
+ ])
+ ->add('enabled', CheckboxType::class, [
+ 'label' => 'sylius.ui.enabled',
+ 'required' => false,
+ ])
+ ->add('qualificationBasis', ChoiceType::class, [
+ 'label' => 'setono_sylius_loyalty.form.tier.qualification_basis',
+ 'choices' => $basisChoices,
+ 'choice_attr' => fn (string $code): array => ['data-unit-label' => $unitLabels[$code] ?? ''],
+ ])
+ ->add('threshold', IntegerType::class, [
+ 'label' => 'setono_sylius_loyalty.form.tier.threshold',
+ 'help' => 'setono_sylius_loyalty.form.tier.threshold_help',
+ ])
+ ->add('earningMultiplier', NumberType::class, [
+ 'label' => 'setono_sylius_loyalty.form.tier.earning_multiplier',
+ 'help' => 'setono_sylius_loyalty.form.tier.earning_multiplier_help',
+ 'scale' => 2,
+ ])
+ ->add('color', ColorType::class, [
+ 'label' => 'setono_sylius_loyalty.form.tier.color',
+ 'required' => false,
+ ])
+ ->add('translations', ResourceTranslationsType::class, [
+ 'label' => 'setono_sylius_loyalty.form.tier.benefits',
+ 'entry_type' => TierTranslationType::class,
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => $this->dataClass,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'setono_sylius_loyalty_tier';
+ }
+}
diff --git a/src/Hint/EarnHintCalculator.php b/src/Hint/EarnHintCalculator.php
new file mode 100644
index 0000000..b28afe0
--- /dev/null
+++ b/src/Hint/EarnHintCalculator.php
@@ -0,0 +1,122 @@
+syntheticCartFactory->create($variant, $channel);
+ if (null === $cart) {
+ return null;
+ }
+
+ return $this->evaluate($cart, $channel, $customer, syntheticCart: true);
+ }
+
+ public function forCart(OrderInterface $cart, ?CustomerInterface $customer): ?int
+ {
+ $channel = $cart->getChannel();
+ if (!$channel instanceof ChannelInterface || $cart->isEmpty()) {
+ return null;
+ }
+
+ return $this->evaluate($cart, $channel, $customer, syntheticCart: false);
+ }
+
+ private function evaluate(OrderInterface $cart, ChannelInterface $channel, ?CustomerInterface $customer, bool $syntheticCart): ?int
+ {
+ $account = null;
+ if ($customer instanceof CustomerInterface) {
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+ // Hints are hidden entirely for customers whose account is disabled
+ if (null !== $account && !$account->isEnabled()) {
+ return null;
+ }
+ }
+
+ $rules = array_values(array_filter(
+ $this->ruleRepository->findForEvaluation($channel, EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE),
+ fn (EarningRuleInterface $rule): bool => $this->isEvaluable($rule, null !== $customer, $syntheticCart),
+ ));
+ if ([] === $rules) {
+ return null;
+ }
+
+ $program = $this->programProvider->getByChannel($channel);
+ $basis = $this->basisCalculator->calculate($cart, $program);
+
+ $context = new EarningContext(
+ channel: $channel,
+ customer: $customer,
+ account: $account,
+ order: $cart,
+ itemAmounts: $basis->itemAmounts,
+ extraAmount: $basis->extraAmount,
+ );
+
+ $points = $this->evaluator->evaluate($rules, $context, $program)->points;
+
+ return $points > 0 ? $points : null;
+ }
+
+ private function isEvaluable(EarningRuleInterface $rule, bool $hasCustomer, bool $syntheticCart): bool
+ {
+ if ($rule->isDryRun()) {
+ return false;
+ }
+
+ if (!$hasCustomer && 'expression' === $rule->getAmountType()) {
+ return false;
+ }
+
+ foreach ($rule->getConditions() as $condition) {
+ $checker = $this->conditionCheckers->get((string) $condition->getType());
+ if (null === $checker) {
+ return false;
+ }
+
+ if ($checker->requiresCustomer() && !$hasCustomer) {
+ return false;
+ }
+
+ if ($checker->requiresCart() && $syntheticCart) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/Hint/EarnHintCalculatorInterface.php b/src/Hint/EarnHintCalculatorInterface.php
new file mode 100644
index 0000000..b3f0f22
--- /dev/null
+++ b/src/Hint/EarnHintCalculatorInterface.php
@@ -0,0 +1,24 @@
+ $orderFactory
+ * @param FactoryInterface $orderItemFactory
+ */
+ public function __construct(
+ private readonly FactoryInterface $orderFactory,
+ private readonly FactoryInterface $orderItemFactory,
+ private readonly OrderItemQuantityModifierInterface $quantityModifier,
+ ) {
+ }
+
+ public function create(ProductVariantInterface $variant, ChannelInterface $channel): ?OrderInterface
+ {
+ $channelPricing = $variant->getChannelPricingForChannel($channel);
+ if (null === $channelPricing || null === $channelPricing->getPrice()) {
+ return null;
+ }
+
+ $order = $this->orderFactory->createNew();
+ $order->setChannel($channel);
+ $order->setCurrencyCode((string) $channel->getBaseCurrency()?->getCode());
+
+ $item = $this->orderItemFactory->createNew();
+ $item->setVariant($variant);
+ $this->quantityModifier->modify($item, 1);
+ $item->setUnitPrice($channelPricing->getPrice());
+ $order->addItem($item);
+
+ self::assignSyntheticId($item);
+
+ return $order;
+ }
+
+ /**
+ * The claiming pipeline correlates items and eligible amounts by item id, which in-memory
+ * items lack.
+ */
+ private static function assignSyntheticId(OrderItemInterface $item): void
+ {
+ $reflection = new \ReflectionProperty($item, 'id');
+ $reflection->setValue($item, 1);
+ Assert::notNull($item->getId());
+ }
+}
diff --git a/src/Hint/SyntheticCartFactoryInterface.php b/src/Hint/SyntheticCartFactoryInterface.php
new file mode 100644
index 0000000..04fb13d
--- /dev/null
+++ b/src/Hint/SyntheticCartFactoryInterface.php
@@ -0,0 +1,17 @@
+ $errors ledger corruption (never auto-fixed)
+ * @param list $warnings expected operational conditions, e.g. overdue open lots
+ * when the expiry cron is behind or a listener deferred an expiration
+ */
+ public function __construct(
+ public readonly LoyaltyAccountInterface $account,
+ public readonly ReplayResult $replay,
+ public readonly array $errors,
+ public readonly array $warnings,
+ ) {
+ }
+
+ public function isHealthy(): bool
+ {
+ return [] === $this->errors;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ $lots = [];
+ foreach ($this->replay->lots as $lotState) {
+ $consumptions = [];
+ foreach ($lotState->getConsumptions() as $consumption) {
+ $consumptions[] = [
+ 'debit' => $consumption->debit->getId(),
+ 'points' => $consumption->points,
+ ];
+ }
+
+ $lots[] = [
+ 'lot' => $lotState->lot->getId(),
+ 'points' => $lotState->lot->getPoints(),
+ 'expiresAt' => $lotState->lot->getExpiresAt()?->format(\DateTimeInterface::ATOM),
+ 'remaining' => $lotState->getRemaining(),
+ 'closedByExpiration' => $lotState->isClosedByExpiration(),
+ 'consumptions' => $consumptions,
+ ];
+ }
+
+ return [
+ 'account' => [
+ 'id' => $this->account->getId(),
+ 'customer' => $this->account->getCustomer()?->getEmail(),
+ 'channel' => $this->account->getChannel()?->getCode(),
+ 'enabled' => $this->account->isEnabled(),
+ 'balance' => $this->account->getBalance(),
+ 'lifetimeEarned' => $this->account->getLifetimeEarned(),
+ 'derivedBalance' => $this->replay->balance,
+ ],
+ 'lots' => $lots,
+ 'errors' => $this->errors,
+ 'warnings' => $this->warnings,
+ ];
+ }
+}
diff --git a/src/Inspector/AccountInspector.php b/src/Inspector/AccountInspector.php
new file mode 100644
index 0000000..03bc62d
--- /dev/null
+++ b/src/Inspector/AccountInspector.php
@@ -0,0 +1,69 @@
+ 0 — the expiry cron
+ * is behind or a listener deferred the expiration.
+ */
+final class AccountInspector implements AccountInspectorInterface
+{
+ public function __construct(
+ private readonly LoyaltyTransactionRepositoryInterface $transactionRepository,
+ private readonly LotReplayerInterface $lotReplayer,
+ ) {
+ }
+
+ public function inspect(LoyaltyAccountInterface $account, ?\DateTimeImmutable $now = null): AccountInspection
+ {
+ $now ??= new \DateTimeImmutable();
+ $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account));
+
+ $errors = $replay->anomalies;
+
+ if ($replay->balance !== $account->getBalance()) {
+ $errors[] = sprintf(
+ 'The cached balance (%d) differs from the ledger sum (%d)',
+ $account->getBalance(),
+ $replay->balance,
+ );
+ }
+
+ foreach ($replay->expirations as $expiration) {
+ if (abs($expiration->expiration->getPoints()) !== $expiration->remainingBefore) {
+ $errors[] = sprintf(
+ 'Expiration entry (id: %s) debits %d points but its lot had %d remaining at that moment',
+ (string) $expiration->expiration->getId(),
+ abs($expiration->expiration->getPoints()),
+ $expiration->remainingBefore,
+ );
+ }
+ }
+
+ $warnings = [];
+ foreach ($replay->lots as $lotState) {
+ $expiresAt = $lotState->lot->getExpiresAt();
+ if ($lotState->isOpen() && null !== $expiresAt && $expiresAt < $now) {
+ $warnings[] = sprintf(
+ 'Lot (id: %s) expired %s with %d points still open — the expiry cron is behind or a listener deferred it',
+ (string) $lotState->lot->getId(),
+ $expiresAt->format('Y-m-d'),
+ $lotState->getRemaining(),
+ );
+ }
+ }
+
+ return new AccountInspection($account, $replay, $errors, $warnings);
+ }
+}
diff --git a/src/Inspector/AccountInspectorInterface.php b/src/Inspector/AccountInspectorInterface.php
new file mode 100644
index 0000000..a072470
--- /dev/null
+++ b/src/Inspector/AccountInspectorInterface.php
@@ -0,0 +1,12 @@
+sortIntoReplayOrder($transactions);
+
+ /** @var list $lots */
+ $lots = [];
+
+ /** @var \SplObjectStorage $lotIndex */
+ $lotIndex = new \SplObjectStorage();
+
+ /** @var list $expirations */
+ $expirations = [];
+
+ /** @var list $anomalies */
+ $anomalies = [];
+
+ /** @var list $deficits */
+ $deficits = [];
+
+ $balance = 0;
+
+ foreach ($transactions as $transaction) {
+ $balance += $transaction->getPoints();
+
+ if ($transaction instanceof ExpireLoyaltyTransactionInterface) {
+ $lot = $transaction->getLot();
+ if (null === $lot || !isset($lotIndex[$lot])) {
+ $anomalies[] = sprintf(
+ 'Expiration entry (id: %s) references a lot that does not precede it in the ledger',
+ self::describeId($transaction),
+ );
+
+ continue;
+ }
+
+ $lotState = $lotIndex[$lot];
+ $expirations[] = new ExpirationState($transaction, $lotState->getRemaining());
+ $lotState->closeByExpiration();
+
+ continue;
+ }
+
+ if ($transaction instanceof CreditLoyaltyTransactionInterface) {
+ if ($transaction->getPoints() < 0) {
+ $anomalies[] = sprintf('Credit (id: %s) has negative points', self::describeId($transaction));
+ }
+
+ $lotState = new LotState($transaction, max(0, $transaction->getPoints()));
+ $lots[] = $lotState;
+ $lotIndex[$transaction] = $lotState;
+
+ $deficits = $this->serveDeficits($lotState, $deficits);
+
+ continue;
+ }
+
+ if ($transaction instanceof DebitLoyaltyTransactionInterface) {
+ if ($transaction->getPoints() > 0) {
+ $anomalies[] = sprintf('Debit (id: %s) has positive points', self::describeId($transaction));
+ }
+
+ $shortfall = $this->consume($transaction, abs($transaction->getPoints()), $lots);
+ if ($shortfall > 0) {
+ $deficits[] = ['debit' => $transaction, 'points' => $shortfall];
+ }
+
+ continue;
+ }
+
+ $anomalies[] = sprintf(
+ 'Transaction (id: %s) is neither a credit nor a debit and cannot be replayed',
+ self::describeId($transaction),
+ );
+ }
+
+ return new ReplayResult(
+ $lots,
+ $expirations,
+ $balance,
+ array_sum(array_column($deficits, 'points')),
+ $anomalies,
+ );
+ }
+
+ /**
+ * Consumes points from open, non-expired lots in consumption order and returns the
+ * unserved remainder.
+ *
+ * @param list $lots
+ */
+ private function consume(DebitLoyaltyTransactionInterface $debit, int $points, array $lots): int
+ {
+ $candidates = array_filter(
+ $lots,
+ static function (LotState $lotState) use ($debit): bool {
+ if (!$lotState->isOpen()) {
+ return false;
+ }
+
+ $expiresAt = $lotState->lot->getExpiresAt();
+
+ return null === $expiresAt || $expiresAt > $debit->getOccurredAt();
+ },
+ );
+
+ usort($candidates, self::compareConsumptionOrder(...));
+
+ foreach ($candidates as $lotState) {
+ if ($points <= 0) {
+ break;
+ }
+
+ $consumed = min($lotState->getRemaining(), $points);
+ $lotState->consume(new Consumption($debit, $consumed));
+ $points -= $consumed;
+ }
+
+ return $points;
+ }
+
+ /**
+ * Serves a carried deficit from a newly opened lot, oldest debit first.
+ *
+ * @param list $deficits
+ *
+ * @return list
+ */
+ private function serveDeficits(LotState $lotState, array $deficits): array
+ {
+ foreach ($deficits as $key => $deficit) {
+ if ($lotState->getRemaining() <= 0) {
+ break;
+ }
+
+ $consumed = min($lotState->getRemaining(), $deficit['points']);
+ $lotState->consume(new Consumption($deficit['debit'], $consumed));
+ $deficits[$key]['points'] -= $consumed;
+ }
+
+ return array_values(array_filter(
+ $deficits,
+ static fn (array $deficit): bool => $deficit['points'] > 0,
+ ));
+ }
+
+ /**
+ * @param iterable $transactions
+ *
+ * @return list
+ */
+ private function sortIntoReplayOrder(iterable $transactions): array
+ {
+ $transactions = is_array($transactions) ? array_values($transactions) : iterator_to_array($transactions, false);
+
+ usort(
+ $transactions,
+ static fn (LoyaltyTransactionInterface $a, LoyaltyTransactionInterface $b): int => [$a->getOccurredAt(), self::idOf($a)] <=> [$b->getOccurredAt(), self::idOf($b)],
+ );
+
+ return $transactions;
+ }
+
+ private static function compareConsumptionOrder(LotState $a, LotState $b): int
+ {
+ $aExpiresAt = $a->lot->getExpiresAt();
+ $bExpiresAt = $b->lot->getExpiresAt();
+
+ // expiresAt ASC NULLS LAST
+ if (null === $aExpiresAt xor null === $bExpiresAt) {
+ return null === $aExpiresAt ? 1 : -1;
+ }
+
+ return [$aExpiresAt, $a->lot->getOccurredAt(), self::idOf($a->lot)]
+ <=> [$bExpiresAt, $b->lot->getOccurredAt(), self::idOf($b->lot)];
+ }
+
+ private static function idOf(LoyaltyTransactionInterface $transaction): int
+ {
+ $id = $transaction->getId();
+
+ return is_int($id) ? $id : \PHP_INT_MAX;
+ }
+
+ private static function describeId(LoyaltyTransactionInterface $transaction): string
+ {
+ $id = $transaction->getId();
+
+ return null === $id ? 'unsaved' : (string) $id;
+ }
+}
diff --git a/src/Ledger/LotReplayerInterface.php b/src/Ledger/LotReplayerInterface.php
new file mode 100644
index 0000000..ef7c764
--- /dev/null
+++ b/src/Ledger/LotReplayerInterface.php
@@ -0,0 +1,20 @@
+ $transactions the account's ledger; it is
+ * re-sorted into replay order (occurredAt ASC, id ASC) internally
+ */
+ public function replay(iterable $transactions): ReplayResult;
+}
diff --git a/src/Ledger/LotState.php b/src/Ledger/LotState.php
new file mode 100644
index 0000000..71ed155
--- /dev/null
+++ b/src/Ledger/LotState.php
@@ -0,0 +1,66 @@
+ */
+ private array $consumptions = [];
+
+ private bool $closedByExpiration = false;
+
+ public function __construct(
+ public readonly CreditLoyaltyTransactionInterface $lot,
+ private int $remaining,
+ ) {
+ }
+
+ public function getRemaining(): int
+ {
+ return $this->remaining;
+ }
+
+ /**
+ * @return list
+ */
+ public function getConsumptions(): array
+ {
+ return $this->consumptions;
+ }
+
+ public function isClosedByExpiration(): bool
+ {
+ return $this->closedByExpiration;
+ }
+
+ public function isOpen(): bool
+ {
+ return $this->remaining > 0 && !$this->closedByExpiration;
+ }
+
+ /**
+ * @internal only the LotReplayer mutates lot states
+ */
+ public function consume(Consumption $consumption): void
+ {
+ $this->remaining -= $consumption->points;
+ $this->consumptions[] = $consumption;
+ }
+
+ /**
+ * @internal only the LotReplayer mutates lot states
+ */
+ public function closeByExpiration(): void
+ {
+ $this->remaining = 0;
+ $this->closedByExpiration = true;
+ }
+}
diff --git a/src/Ledger/LoyaltyLedger.php b/src/Ledger/LoyaltyLedger.php
new file mode 100644
index 0000000..a36b64a
--- /dev/null
+++ b/src/Ledger/LoyaltyLedger.php
@@ -0,0 +1,655 @@
+ $accountClass
+ */
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly ManagerRegistry $managerRegistry,
+ private readonly LoyaltyAccountProviderInterface $accountProvider,
+ private readonly LoyaltyTransactionRepositoryInterface $transactionRepository,
+ private readonly LotReplayerInterface $lotReplayer,
+ private readonly LoyaltyProgramProviderInterface $programProvider,
+ private readonly TierEvaluatorInterface $tierEvaluator,
+ private readonly EventDispatcherInterface $eventDispatcher,
+ private readonly LoggerInterface $logger,
+ private readonly string $accountClass,
+ ) {
+ }
+
+ public function earnOrder(
+ OrderInterface $order,
+ int $points,
+ array $rulesBreakdown = [],
+ ?int $basisAmount = null,
+ ?\DateTimeImmutable $expiresAt = null,
+ ): ?EarnOrderLoyaltyTransactionInterface {
+ Assert::greaterThan($points, 0);
+
+ $account = $this->accountFromOrder($order);
+ if (null === $account) {
+ return null;
+ }
+
+ if (!$account->isEnabled()) {
+ $this->logger->info(sprintf(
+ '[Loyalty] Skipped earning for order %s: the loyalty account (id: %d) is disabled',
+ (string) $order->getNumber(),
+ (int) $account->getId(),
+ ));
+
+ return null;
+ }
+
+ /** @var EarnOrderLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points, $rulesBreakdown, $basisAmount, $expiresAt): ?EarnOrderLoyaltyTransactionInterface {
+ $awarding = new AwardingPoints($account, $points, $expiresAt, $order, null, $rulesBreakdown);
+ $this->eventDispatcher->dispatch($awarding);
+ if ($awarding->isCancelled() || $awarding->getPoints() <= 0) {
+ return null;
+ }
+
+ $transaction = new EarnOrderLoyaltyTransaction();
+ $transaction->setOrder($order);
+ $transaction->setRulesBreakdown($rulesBreakdown);
+ $transaction->setBasisAmount($basisAmount);
+
+ $this->credit($account, $transaction, $awarding->getPoints(), $awarding->getExpiresAt());
+
+ $this->evaluateTier($account);
+
+ $postCommitEvents[] = new PointsEarned($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function earnAction(
+ LoyaltyAccountInterface $account,
+ int $points,
+ string $sourceIdentifier,
+ array $rulesBreakdown = [],
+ ?\DateTimeImmutable $expiresAt = null,
+ ): ?EarnActionLoyaltyTransactionInterface {
+ Assert::greaterThan($points, 0);
+
+ if (!$account->isEnabled()) {
+ $this->logger->info(sprintf(
+ '[Loyalty] Skipped earning for source "%s": the loyalty account (id: %d) is disabled',
+ $sourceIdentifier,
+ (int) $account->getId(),
+ ));
+
+ return null;
+ }
+
+ /** @var EarnActionLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $sourceIdentifier, $rulesBreakdown, $expiresAt): ?EarnActionLoyaltyTransactionInterface {
+ $awarding = new AwardingPoints($account, $points, $expiresAt, null, $sourceIdentifier, $rulesBreakdown);
+ $this->eventDispatcher->dispatch($awarding);
+ if ($awarding->isCancelled() || $awarding->getPoints() <= 0) {
+ return null;
+ }
+
+ $transaction = new EarnActionLoyaltyTransaction();
+ $transaction->setSourceIdentifier($sourceIdentifier);
+ $transaction->setRulesBreakdown($rulesBreakdown);
+
+ $this->credit($account, $transaction, $awarding->getPoints(), $awarding->getExpiresAt());
+
+ $this->evaluateTier($account);
+
+ $postCommitEvents[] = new PointsEarned($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function earnReferral(
+ LoyaltyAccountInterface $account,
+ int $points,
+ ReferralInterface $referral,
+ ?\DateTimeImmutable $expiresAt = null,
+ ): ?EarnReferralLoyaltyTransactionInterface {
+ Assert::greaterThan($points, 0);
+
+ /** @var EarnReferralLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $referral, $expiresAt): ?EarnReferralLoyaltyTransactionInterface {
+ $awarding = new AwardingPoints($account, $points, $expiresAt, null, null, []);
+ $this->eventDispatcher->dispatch($awarding);
+ if ($awarding->isCancelled() || $awarding->getPoints() <= 0) {
+ return null;
+ }
+
+ $transaction = new EarnReferralLoyaltyTransaction();
+ $transaction->setReferral($referral);
+
+ $this->credit($account, $transaction, $awarding->getPoints(), $awarding->getExpiresAt());
+
+ $this->evaluateTier($account);
+
+ $postCommitEvents[] = new PointsEarned($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function redeem(OrderInterface $order, int $points): ?RedeemLoyaltyTransactionInterface
+ {
+ Assert::greaterThan($points, 0);
+
+ $account = $this->accountFromOrder($order);
+ if (null === $account) {
+ throw new AccountNotFoundException(sprintf(
+ 'No loyalty account can be resolved for order %s',
+ (string) $order->getNumber(),
+ ));
+ }
+
+ /** @var RedeemLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points): ?RedeemLoyaltyTransactionInterface {
+ if (null !== $this->transactionRepository->findRedeemTransaction($order)) {
+ $this->logger->info(sprintf(
+ '[Loyalty] Order %s already has a redemption; skipping duplicate debit',
+ (string) $order->getNumber(),
+ ));
+
+ return null;
+ }
+
+ if (!$account->isEnabled()) {
+ throw new LedgerConflictException(sprintf(
+ 'The loyalty account (id: %d) is disabled and cannot redeem points',
+ (int) $account->getId(),
+ ));
+ }
+
+ if ($account->getBalance() < $points) {
+ throw InsufficientBalanceException::create($account, $points);
+ }
+
+ $redeeming = new RedeemingPoints($account, $points, $order);
+ $this->eventDispatcher->dispatch($redeeming);
+ if ($redeeming->isCancelled()) {
+ throw new LedgerConflictException('The redemption was cancelled by a listener');
+ }
+
+ $transaction = new RedeemLoyaltyTransaction();
+ $transaction->setOrder($order);
+
+ $this->debit($account, $transaction, $points);
+
+ $postCommitEvents[] = new PointsRedeemed($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function rollbackRedeem(OrderInterface $order): ?RedeemRollbackLoyaltyTransactionInterface
+ {
+ $redeem = $this->transactionRepository->findRedeemTransaction($order);
+ if (null === $redeem) {
+ return null;
+ }
+
+ $account = $redeem->getAccount();
+ Assert::notNull($account);
+
+ /** @var RedeemRollbackLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($redeem): ?RedeemRollbackLoyaltyTransactionInterface {
+ if ($this->transactionRepository->hasRollback($redeem)) {
+ $this->logger->info(sprintf(
+ '[Loyalty] The redemption (id: %d) was already rolled back; skipping',
+ (int) $redeem->getId(),
+ ));
+
+ return null;
+ }
+
+ $transaction = new RedeemRollbackLoyaltyTransaction();
+ $transaction->setRedeem($redeem);
+
+ $this->credit(
+ $account,
+ $transaction,
+ abs($redeem->getPoints()),
+ $this->resolveRollbackExpiry($account, $redeem),
+ countsTowardLifetimeEarned: false,
+ );
+
+ $postCommitEvents[] = new RedemptionRolledBack($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function expire(LoyaltyAccountInterface $account, ?\DateTimeImmutable $now = null): array
+ {
+ $now ??= new \DateTimeImmutable();
+
+ /** @var list $transactions */
+ $transactions = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($now): array {
+ $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account));
+
+ $transactions = [];
+ foreach ($replay->lots as $lotState) {
+ $expiresAt = $lotState->lot->getExpiresAt();
+ if (null === $expiresAt || $expiresAt > $now || $lotState->isClosedByExpiration()) {
+ continue;
+ }
+
+ $expiring = new ExpiringPoints($account, $lotState->lot, $lotState->getRemaining());
+ $this->eventDispatcher->dispatch($expiring);
+ if ($expiring->isCancelled()) {
+ continue;
+ }
+
+ $transaction = new ExpireLoyaltyTransaction();
+ $transaction->setLot($lotState->lot);
+
+ // Zero-point entries close fully consumed lots so the daily selection stays exact
+ $this->debit($account, $transaction, $lotState->getRemaining(), occurredAt: $now);
+
+ $postCommitEvents[] = new PointsExpired($transaction);
+ $transactions[] = $transaction;
+ }
+
+ return $transactions;
+ },
+ ) ?? [];
+
+ return $transactions;
+ }
+
+ public function clawback(OrderInterface $order, int $points): ?ClawbackLoyaltyTransactionInterface
+ {
+ Assert::greaterThan($points, 0);
+
+ $earn = $this->transactionRepository->findEarnOrderTransaction($order);
+ if (null === $earn) {
+ return null;
+ }
+
+ // A lookup-first guard keeps idempotent replays from tripping the unique constraint
+ // (which would reset the entity manager mid-request); the constraint remains the
+ // concurrency backstop
+ if (null !== $this->transactionRepository->findClawbackForEarn($earn)) {
+ return null;
+ }
+
+ $account = $earn->getAccount();
+ Assert::notNull($account);
+
+ /** @var ClawbackLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points, $earn): ?ClawbackLoyaltyTransactionInterface {
+ $clawingBack = new ClawingBackPoints($account, $points, $order, $earn);
+ $this->eventDispatcher->dispatch($clawingBack);
+ if ($clawingBack->isCancelled()) {
+ return null;
+ }
+
+ $points = $clawingBack->getPoints();
+
+ $channel = $account->getChannel();
+ Assert::notNull($channel);
+ $program = $this->programProvider->getByChannel($channel);
+ if (LoyaltyProgramInterface::CLAWBACK_POLICY_CLAMP_TO_ZERO === $program->getClawbackPolicy()) {
+ // Reduce the debit at write time so the balance lands at exactly zero; the
+ // ledger entry records what was actually debited
+ $points = min($points, max(0, $account->getBalance()));
+ }
+
+ $transaction = new ClawbackLoyaltyTransaction();
+ $transaction->setOrder($order);
+ $transaction->setEarn($earn);
+
+ $this->debit($account, $transaction, $points);
+
+ $postCommitEvents[] = new PointsClawedBack($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function clawbackCredit(CreditLoyaltyTransactionInterface $earn, ?OrderInterface $order = null): ?ClawbackLoyaltyTransactionInterface
+ {
+ $account = $earn->getAccount();
+ Assert::notNull($account);
+
+ $points = $earn->getPoints();
+ if ($points <= 0) {
+ return null;
+ }
+
+ if (null !== $this->transactionRepository->findClawbackForEarn($earn)) {
+ return null;
+ }
+
+ /** @var ClawbackLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($order, $points, $earn): ?ClawbackLoyaltyTransactionInterface {
+ $clawingBack = new ClawingBackPoints($account, $points, $order, $earn);
+ $this->eventDispatcher->dispatch($clawingBack);
+ if ($clawingBack->isCancelled()) {
+ return null;
+ }
+
+ $points = $clawingBack->getPoints();
+
+ $channel = $account->getChannel();
+ Assert::notNull($channel);
+ $program = $this->programProvider->getByChannel($channel);
+ if (LoyaltyProgramInterface::CLAWBACK_POLICY_CLAMP_TO_ZERO === $program->getClawbackPolicy()) {
+ $points = min($points, max(0, $account->getBalance()));
+ }
+
+ $transaction = new ClawbackLoyaltyTransaction();
+ $transaction->setOrder($order);
+ $transaction->setEarn($earn);
+
+ $this->debit($account, $transaction, $points);
+
+ $postCommitEvents[] = new PointsClawedBack($transaction);
+
+ return $transaction;
+ },
+ );
+
+ return $transaction;
+ }
+
+ public function manualCredit(
+ LoyaltyAccountInterface $account,
+ int $points,
+ string $reason,
+ string $note,
+ ?AdminUserInterface $adminUser = null,
+ ): ManualCreditLoyaltyTransactionInterface {
+ Assert::greaterThan($points, 0);
+ Assert::stringNotEmpty($note);
+
+ /** @var ManualCreditLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $reason, $note, $adminUser): ManualCreditLoyaltyTransactionInterface {
+ $transaction = new ManualCreditLoyaltyTransaction();
+ $transaction->setReason($reason);
+ $transaction->setNote($note);
+ $transaction->setAdminUser($adminUser);
+
+ $this->credit($account, $transaction, $points, null);
+
+ $this->evaluateTier($account);
+
+ $postCommitEvents[] = new ManualAdjustment($transaction);
+
+ return $transaction;
+ },
+ );
+
+ Assert::notNull($transaction);
+
+ return $transaction;
+ }
+
+ public function manualDebit(
+ LoyaltyAccountInterface $account,
+ int $points,
+ string $reason,
+ string $note,
+ ?AdminUserInterface $adminUser = null,
+ ): ManualDebitLoyaltyTransactionInterface {
+ Assert::greaterThan($points, 0);
+ Assert::stringNotEmpty($note);
+
+ /** @var ManualDebitLoyaltyTransactionInterface|null $transaction */
+ $transaction = $this->transactional(
+ $account,
+ function (LoyaltyAccountInterface $account, array &$postCommitEvents) use ($points, $reason, $note, $adminUser): ManualDebitLoyaltyTransactionInterface {
+ $transaction = new ManualDebitLoyaltyTransaction();
+ $transaction->setReason($reason);
+ $transaction->setNote($note);
+ $transaction->setAdminUser($adminUser);
+
+ $this->debit($account, $transaction, $points);
+
+ $postCommitEvents[] = new ManualAdjustment($transaction);
+
+ return $transaction;
+ },
+ );
+
+ Assert::notNull($transaction);
+
+ return $transaction;
+ }
+
+ /**
+ * Runs the given callback with the account row locked (pessimistic write) inside a
+ * transaction, then dispatches the collected post-commit events. A unique constraint
+ * violation is an idempotent no-op by design; any plugin exception leaves the (closed)
+ * entity manager reset so the surrounding request stays usable.
+ *
+ * @param callable(LoyaltyAccountInterface, array): mixed $callback
+ */
+ private function transactional(LoyaltyAccountInterface $account, callable $callback): mixed
+ {
+ $accountId = $account->getId();
+ Assert::notNull($accountId, 'The loyalty account must be persisted before writing ledger entries');
+
+ /** @var array $postCommitEvents */
+ $postCommitEvents = [];
+
+ try {
+ $result = $this->entityManager->wrapInTransaction(
+ function () use ($accountId, $callback, &$postCommitEvents): mixed {
+ $account = $this->entityManager
+ ->getRepository($this->accountClass)
+ ->find($accountId, LockMode::PESSIMISTIC_WRITE)
+ ;
+ if (!$account instanceof LoyaltyAccountInterface) {
+ throw new AccountNotFoundException(sprintf('The loyalty account (id: %s) no longer exists', (string) $accountId));
+ }
+
+ return $callback($account, $postCommitEvents);
+ },
+ );
+ } catch (UniqueConstraintViolationException $e) {
+ // The write already happened (event redelivery, concurrent request) — a no-op by design
+ $this->managerRegistry->resetManager();
+ $this->logger->info(sprintf('[Loyalty] Idempotent no-op for account (id: %d): %s', $accountId, $e->getMessage()));
+
+ return null;
+ } catch (ExceptionInterface $e) {
+ $this->managerRegistry->resetManager();
+
+ throw $e;
+ }
+
+ foreach ($postCommitEvents as $event) {
+ $this->eventDispatcher->dispatch($event);
+ }
+
+ return $result;
+ }
+
+ /**
+ * The qualification bases derive their metrics from ledger queries, so the pending credit
+ * must be flushed (still inside the surrounding transaction) before the tier evaluator
+ * runs — otherwise the earn that crosses a threshold would not upgrade until the nightly
+ * reconciliation.
+ */
+ private function evaluateTier(LoyaltyAccountInterface $account): void
+ {
+ $this->entityManager->flush();
+ $this->tierEvaluator->evaluate($account);
+ }
+
+ private function credit(
+ LoyaltyAccountInterface $account,
+ CreditLoyaltyTransaction $transaction,
+ int $points,
+ ?\DateTimeImmutable $expiresAt,
+ bool $countsTowardLifetimeEarned = true,
+ ): void {
+ $transaction->setAccount($account);
+ $transaction->setPoints($points);
+ $transaction->setExpiresAt($expiresAt);
+ $this->entityManager->persist($transaction);
+
+ $account->setBalance($account->getBalance() + $points);
+ if ($countsTowardLifetimeEarned) {
+ $account->setLifetimeEarned($account->getLifetimeEarned() + $points);
+ }
+ }
+
+ private function debit(
+ LoyaltyAccountInterface $account,
+ DebitLoyaltyTransaction $transaction,
+ int $points,
+ ?\DateTimeImmutable $occurredAt = null,
+ ): void {
+ $transaction->setAccount($account);
+ $transaction->setPoints(-$points);
+ if (null !== $occurredAt) {
+ $transaction->setOccurredAt($occurredAt);
+ }
+ $this->entityManager->persist($transaction);
+
+ $account->setBalance($account->getBalance() - $points);
+ }
+
+ /**
+ * The new lot restores the redeemed points carrying the earliest surviving expiry of the
+ * lots the replay attributes to the rolled-back debit (simplification accepted by design).
+ */
+ private function resolveRollbackExpiry(LoyaltyAccountInterface $account, RedeemLoyaltyTransactionInterface $redeem): ?\DateTimeImmutable
+ {
+ $replay = $this->lotReplayer->replay($this->transactionRepository->findForReplay($account));
+ $now = new \DateTimeImmutable();
+
+ $earliest = null;
+ foreach ($replay->lots as $lotState) {
+ foreach ($lotState->getConsumptions() as $consumption) {
+ if ($consumption->debit !== $redeem) {
+ continue;
+ }
+
+ $expiresAt = $lotState->lot->getExpiresAt();
+ if (null === $expiresAt || $expiresAt <= $now) {
+ continue;
+ }
+
+ if (null === $earliest || $expiresAt < $earliest) {
+ $earliest = $expiresAt;
+ }
+ }
+ }
+
+ return $earliest;
+ }
+
+ private function accountFromOrder(OrderInterface $order): ?LoyaltyAccountInterface
+ {
+ $customer = $order->getCustomer();
+ $channel = $order->getChannel();
+
+ if (!$customer instanceof CustomerInterface || null === $channel) {
+ return null;
+ }
+
+ return $this->accountProvider->getByCustomerAndChannel($customer, $channel);
+ }
+}
diff --git a/src/Ledger/LoyaltyLedgerInterface.php b/src/Ledger/LoyaltyLedgerInterface.php
new file mode 100644
index 0000000..6dd70ca
--- /dev/null
+++ b/src/Ledger/LoyaltyLedgerInterface.php
@@ -0,0 +1,117 @@
+ $rulesBreakdown
+ */
+ public function earnOrder(
+ OrderInterface $order,
+ int $points,
+ array $rulesBreakdown = [],
+ ?int $basisAmount = null,
+ ?\DateTimeImmutable $expiresAt = null,
+ ): ?EarnOrderLoyaltyTransactionInterface;
+
+ /**
+ * @param array $rulesBreakdown
+ */
+ public function earnAction(
+ LoyaltyAccountInterface $account,
+ int $points,
+ string $sourceIdentifier,
+ array $rulesBreakdown = [],
+ ?\DateTimeImmutable $expiresAt = null,
+ ): ?EarnActionLoyaltyTransactionInterface;
+
+ /**
+ * Debits the applied points when the order completes checkout. The balance and the
+ * account's enabled state are re-validated inside the account lock.
+ *
+ * @throws InsufficientBalanceException if the balance no longer covers the points
+ * @throws LedgerConflictException if the account is disabled or a listener cancelled the redemption
+ */
+ /**
+ * Credits referral reward points, idempotent per (account, referral).
+ */
+ public function earnReferral(
+ LoyaltyAccountInterface $account,
+ int $points,
+ ReferralInterface $referral,
+ ?\DateTimeImmutable $expiresAt = null,
+ ): ?EarnReferralLoyaltyTransactionInterface;
+
+ public function redeem(OrderInterface $order, int $points): ?RedeemLoyaltyTransactionInterface;
+
+ /**
+ * Restores the points of the order's redemption as a new lot carrying the earliest
+ * surviving expiry of the lots the replay attributes to the rolled-back debit.
+ */
+ public function rollbackRedeem(OrderInterface $order): ?RedeemRollbackLoyaltyTransactionInterface;
+
+ /**
+ * Writes one expiration entry per expired open lot — including zero-point entries closing
+ * fully consumed lots. Runs for disabled accounts too, so the liability doesn't freeze.
+ *
+ * @return list
+ */
+ public function expire(LoyaltyAccountInterface $account, ?\DateTimeImmutable $now = null): array;
+
+ /**
+ * Debits the points earned for the order (looked up via its earn transaction; no-op if
+ * none). Public extension point for project-level (partial) refund integrations.
+ */
+ public function clawback(OrderInterface $order, int $points): ?ClawbackLoyaltyTransactionInterface;
+
+ /**
+ * Claws back an arbitrary credit (referral rewards, custom credits) — idempotent per
+ * credit via the (type, earn_id) unique constraint. The optional order records what
+ * caused the clawback.
+ */
+ public function clawbackCredit(CreditLoyaltyTransactionInterface $earn, ?OrderInterface $order = null): ?ClawbackLoyaltyTransactionInterface;
+
+ public function manualCredit(
+ LoyaltyAccountInterface $account,
+ int $points,
+ string $reason,
+ string $note,
+ ?AdminUserInterface $adminUser = null,
+ ): ManualCreditLoyaltyTransactionInterface;
+
+ public function manualDebit(
+ LoyaltyAccountInterface $account,
+ int $points,
+ string $reason,
+ string $note,
+ ?AdminUserInterface $adminUser = null,
+ ): ManualDebitLoyaltyTransactionInterface;
+}
diff --git a/src/Ledger/ReplayResult.php b/src/Ledger/ReplayResult.php
new file mode 100644
index 0000000..a40f2f0
--- /dev/null
+++ b/src/Ledger/ReplayResult.php
@@ -0,0 +1,47 @@
+ $lots in the order the lots were opened
+ * @param list $expirations
+ * @param int $balance the signed sum of all replayed transactions
+ * @param int $deficit unserved debit points carried at the end of the replay (only under
+ * the allow_negative clawback policy or when debits skipped expired lots)
+ * @param list $anomalies human-readable descriptions of ledger inconsistencies
+ * encountered during replay (never auto-fixed)
+ */
+ public function __construct(
+ public readonly array $lots,
+ public readonly array $expirations,
+ public readonly int $balance,
+ public readonly int $deficit,
+ public readonly array $anomalies,
+ ) {
+ }
+
+ public function getLotState(CreditLoyaltyTransactionInterface $lot): ?LotState
+ {
+ foreach ($this->lots as $lotState) {
+ if ($lotState->lot === $lot) {
+ return $lotState;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return list
+ */
+ public function getOpenLots(): array
+ {
+ return array_values(array_filter($this->lots, static fn (LotState $lotState): bool => $lotState->isOpen()));
+ }
+}
diff --git a/src/LoyaltyAdjustmentTypes.php b/src/LoyaltyAdjustmentTypes.php
new file mode 100644
index 0000000..933786d
--- /dev/null
+++ b/src/LoyaltyAdjustmentTypes.php
@@ -0,0 +1,18 @@
+entityManager->find($this->orderClass, $message->orderId);
+ if (!$order instanceof OrderInterface) {
+ $this->logger->info(sprintf('[Loyalty] Order %d no longer exists; nothing to award', $message->orderId));
+
+ return;
+ }
+
+ $customer = $order->getCustomer();
+ $channel = $order->getChannel();
+ if (!$customer instanceof CustomerInterface || null === $channel) {
+ // Guest checkout — guests earn nothing at order time (retroactive claim on registration)
+ return;
+ }
+
+ $program = $this->programProvider->getByChannel($channel);
+ if (!$this->awardMomentReached($order, $program)) {
+ return;
+ }
+
+ // Referral qualification shares the award moment; it decides once per referral and is
+ // independent of whether any earning rules exist
+ $this->referralQualifier->qualify($order);
+
+ $rules = $this->ruleRepository->findForEvaluation($channel, EarningRuleInterface::TRIGGER_ORDER_ELIGIBLE);
+ if ([] === $rules) {
+ return;
+ }
+
+ $account = $this->accountProvider->getByCustomerAndChannel($customer, $channel);
+ if (!$account->isEnabled()) {
+ return;
+ }
+
+ $basis = $this->basisCalculator->calculate($order, $program);
+
+ $context = new EarningContext(
+ channel: $channel,
+ customer: $customer,
+ account: $account,
+ order: $order,
+ itemAmounts: $basis->itemAmounts,
+ extraAmount: $basis->extraAmount,
+ );
+
+ $result = $this->evaluator->evaluate($rules, $context, $program);
+
+ $this->dryRunLogger->log($result, $account, $order);
+
+ if ($result->points <= 0) {
+ return;
+ }
+
+ $this->ledger->earnOrder(
+ $order,
+ $result->points,
+ $result->rulesBreakdown,
+ $basis->getTotal(),
+ self::expiresAt($program),
+ );
+ }
+
+ private function awardMomentReached(OrderInterface $order, LoyaltyProgramInterface $program): bool
+ {
+ if (LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED === $program->getAwardOrderPointsAt()) {
+ return OrderInterface::STATE_FULFILLED === $order->getState();
+ }
+
+ // Handles partial payments: award only when the whole order is paid
+ return OrderPaymentStates::STATE_PAID === $order->getPaymentState();
+ }
+
+ private static function expiresAt(LoyaltyProgramInterface $program): ?\DateTimeImmutable
+ {
+ $days = $program->getPointsExpiryDays();
+
+ return null === $days ? null : new \DateTimeImmutable(sprintf('+%d days', $days));
+ }
+}
diff --git a/src/Message/Handler/ClaimPastOrderPointsHandler.php b/src/Message/Handler/ClaimPastOrderPointsHandler.php
new file mode 100644
index 0000000..311573a
--- /dev/null
+++ b/src/Message/Handler/ClaimPastOrderPointsHandler.php
@@ -0,0 +1,91 @@
+entityManager->find($this->customerClass, $message->customerId);
+ $channel = $this->entityManager->find($this->channelClass, $message->channelId);
+
+ if (!$customer instanceof CustomerInterface || !$channel instanceof ChannelInterface) {
+ return;
+ }
+
+ $program = $this->programProvider->getByChannel($channel);
+ if (!$program->isRetroactiveGuestPoints()) {
+ return;
+ }
+
+ foreach ($this->eligibleOrderIds($customer, $channel, $program) as $orderId) {
+ $this->messageBus->dispatch(
+ new Envelope(new AwardOrderPoints($orderId), [new DispatchAfterCurrentBusStamp()]),
+ );
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function eligibleOrderIds(
+ CustomerInterface $customer,
+ ChannelInterface $channel,
+ LoyaltyProgramInterface $program,
+ ): array {
+ $queryBuilder = $this->entityManager->createQueryBuilder()
+ ->select('o.id')
+ ->from($this->orderClass, 'o')
+ ->andWhere('o.customer = :customer')
+ ->andWhere('o.channel = :channel')
+ ->setParameter('customer', $customer)
+ ->setParameter('channel', $channel)
+ ;
+
+ if (LoyaltyProgramInterface::AWARD_ORDER_POINTS_AT_ORDER_FULFILLED === $program->getAwardOrderPointsAt()) {
+ $queryBuilder->andWhere('o.state = :state')->setParameter('state', OrderInterface::STATE_FULFILLED);
+ } else {
+ $queryBuilder->andWhere('o.paymentState = :paymentState')->setParameter('paymentState', OrderPaymentStates::STATE_PAID);
+ }
+
+ /** @var list $rows */
+ $rows = $queryBuilder->getQuery()->getScalarResult();
+
+ return array_map(static fn (array $row): int => (int) $row['id'], $rows);
+ }
+}
diff --git a/src/Model/ClawbackLoyaltyTransaction.php b/src/Model/ClawbackLoyaltyTransaction.php
new file mode 100644
index 0000000..db9109b
--- /dev/null
+++ b/src/Model/ClawbackLoyaltyTransaction.php
@@ -0,0 +1,39 @@
+order;
+ }
+
+ public function setOrder(?OrderInterface $order): void
+ {
+ $this->order = $order;
+ }
+
+ public function getEarn(): ?CreditLoyaltyTransactionInterface
+ {
+ return $this->earn;
+ }
+
+ public function setEarn(?CreditLoyaltyTransactionInterface $earn): void
+ {
+ $this->earn = $earn;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'clawback';
+ }
+}
diff --git a/src/Model/ClawbackLoyaltyTransactionInterface.php b/src/Model/ClawbackLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..1a13686
--- /dev/null
+++ b/src/Model/ClawbackLoyaltyTransactionInterface.php
@@ -0,0 +1,23 @@
+expiresAt;
+ }
+
+ public function setExpiresAt(?\DateTimeImmutable $expiresAt): void
+ {
+ $this->expiresAt = $expiresAt;
+ }
+}
diff --git a/src/Model/CreditLoyaltyTransactionInterface.php b/src/Model/CreditLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..5b6630e
--- /dev/null
+++ b/src/Model/CreditLoyaltyTransactionInterface.php
@@ -0,0 +1,16 @@
+|null */
+ protected ?array $details = [];
+
+ protected \DateTimeImmutable $createdAt;
+
+ public function __construct()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getRule(): ?EarningRuleInterface
+ {
+ return $this->rule;
+ }
+
+ public function setRule(?EarningRuleInterface $rule): void
+ {
+ $this->rule = $rule;
+ }
+
+ public function getAccount(): ?LoyaltyAccountInterface
+ {
+ return $this->account;
+ }
+
+ public function setAccount(?LoyaltyAccountInterface $account): void
+ {
+ $this->account = $account;
+ }
+
+ public function getOrder(): ?OrderInterface
+ {
+ return $this->order;
+ }
+
+ public function setOrder(?OrderInterface $order): void
+ {
+ $this->order = $order;
+ }
+
+ public function getPoints(): int
+ {
+ return $this->points;
+ }
+
+ public function setPoints(int $points): void
+ {
+ $this->points = $points;
+ }
+
+ public function getDetails(): array
+ {
+ return $this->details ?? [];
+ }
+
+ public function setDetails(array $details): void
+ {
+ $this->details = $details;
+ }
+
+ public function getCreatedAt(): \DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+}
diff --git a/src/Model/DryRunResultInterface.php b/src/Model/DryRunResultInterface.php
new file mode 100644
index 0000000..7829863
--- /dev/null
+++ b/src/Model/DryRunResultInterface.php
@@ -0,0 +1,43 @@
+
+ */
+ public function getDetails(): array;
+
+ /**
+ * @param array $details
+ */
+ public function setDetails(array $details): void;
+
+ public function getCreatedAt(): \DateTimeImmutable;
+}
diff --git a/src/Model/EarnActionLoyaltyTransaction.php b/src/Model/EarnActionLoyaltyTransaction.php
new file mode 100644
index 0000000..7ca09fb
--- /dev/null
+++ b/src/Model/EarnActionLoyaltyTransaction.php
@@ -0,0 +1,27 @@
+sourceIdentifier;
+ }
+
+ public function setSourceIdentifier(?string $sourceIdentifier): void
+ {
+ $this->sourceIdentifier = $sourceIdentifier;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'earn_action';
+ }
+}
diff --git a/src/Model/EarnActionLoyaltyTransactionInterface.php b/src/Model/EarnActionLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..fac8660
--- /dev/null
+++ b/src/Model/EarnActionLoyaltyTransactionInterface.php
@@ -0,0 +1,17 @@
+order;
+ }
+
+ public function setOrder(?OrderInterface $order): void
+ {
+ $this->order = $order;
+ }
+
+ public function getBasisAmount(): ?int
+ {
+ return $this->basisAmount;
+ }
+
+ public function setBasisAmount(?int $basisAmount): void
+ {
+ $this->basisAmount = $basisAmount;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'earn_order';
+ }
+}
diff --git a/src/Model/EarnOrderLoyaltyTransactionInterface.php b/src/Model/EarnOrderLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..984e692
--- /dev/null
+++ b/src/Model/EarnOrderLoyaltyTransactionInterface.php
@@ -0,0 +1,25 @@
+referral;
+ }
+
+ public function setReferral(?ReferralInterface $referral): void
+ {
+ $this->referral = $referral;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'earn_referral';
+ }
+}
diff --git a/src/Model/EarnReferralLoyaltyTransactionInterface.php b/src/Model/EarnReferralLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..5f42624
--- /dev/null
+++ b/src/Model/EarnReferralLoyaltyTransactionInterface.php
@@ -0,0 +1,12 @@
+|null */
+ protected ?array $scopeConfiguration = [];
+
+ protected string $conditionsMatch = self::CONDITIONS_MATCH_ALL;
+
+ /** @var Collection */
+ protected Collection $conditions;
+
+ protected ?string $amountType = null;
+
+ /** @var array|null */
+ protected ?array $amountConfiguration = [];
+
+ protected ?\DateTimeImmutable $startsAt = null;
+
+ protected ?\DateTimeImmutable $endsAt = null;
+
+ protected bool $stackable = true;
+
+ public function __construct()
+ {
+ $this->conditions = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getChannel(): ?ChannelInterface
+ {
+ return $this->channel;
+ }
+
+ public function setChannel(?ChannelInterface $channel): void
+ {
+ $this->channel = $channel;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(?string $name): void
+ {
+ $this->name = $name;
+ }
+
+ public function isDryRun(): bool
+ {
+ return $this->dryRun;
+ }
+
+ public function setDryRun(bool $dryRun): void
+ {
+ $this->dryRun = $dryRun;
+ }
+
+ public function getPriority(): int
+ {
+ return $this->priority;
+ }
+
+ public function setPriority(int $priority): void
+ {
+ $this->priority = $priority;
+ }
+
+ public function getTrigger(): string
+ {
+ return $this->trigger;
+ }
+
+ public function setTrigger(string $trigger): void
+ {
+ $this->trigger = $trigger;
+ }
+
+ public function getScope(): string
+ {
+ return $this->scope;
+ }
+
+ public function setScope(string $scope): void
+ {
+ $this->scope = $scope;
+ }
+
+ public function getScopeConfiguration(): array
+ {
+ return $this->scopeConfiguration ?? [];
+ }
+
+ public function setScopeConfiguration(array $scopeConfiguration): void
+ {
+ $this->scopeConfiguration = $scopeConfiguration;
+ }
+
+ public function getConditionsMatch(): string
+ {
+ return $this->conditionsMatch;
+ }
+
+ public function setConditionsMatch(string $conditionsMatch): void
+ {
+ $this->conditionsMatch = $conditionsMatch;
+ }
+
+ public function getConditions(): Collection
+ {
+ return $this->conditions;
+ }
+
+ public function addCondition(EarningRuleConditionInterface $condition): void
+ {
+ if (!$this->hasCondition($condition)) {
+ $this->conditions->add($condition);
+ $condition->setRule($this);
+ }
+ }
+
+ public function removeCondition(EarningRuleConditionInterface $condition): void
+ {
+ if ($this->hasCondition($condition)) {
+ $this->conditions->removeElement($condition);
+ $condition->setRule(null);
+ }
+ }
+
+ public function hasCondition(EarningRuleConditionInterface $condition): bool
+ {
+ return $this->conditions->contains($condition);
+ }
+
+ public function getAmountType(): ?string
+ {
+ return $this->amountType;
+ }
+
+ public function setAmountType(?string $amountType): void
+ {
+ $this->amountType = $amountType;
+ }
+
+ public function getAmountConfiguration(): array
+ {
+ return $this->amountConfiguration ?? [];
+ }
+
+ public function setAmountConfiguration(array $amountConfiguration): void
+ {
+ $this->amountConfiguration = $amountConfiguration;
+ }
+
+ public function getStartsAt(): ?\DateTimeImmutable
+ {
+ return $this->startsAt;
+ }
+
+ public function setStartsAt(?\DateTimeImmutable $startsAt): void
+ {
+ $this->startsAt = $startsAt;
+ }
+
+ public function getEndsAt(): ?\DateTimeImmutable
+ {
+ return $this->endsAt;
+ }
+
+ public function setEndsAt(?\DateTimeImmutable $endsAt): void
+ {
+ $this->endsAt = $endsAt;
+ }
+
+ public function isStackable(): bool
+ {
+ return $this->stackable;
+ }
+
+ public function setStackable(bool $stackable): void
+ {
+ $this->stackable = $stackable;
+ }
+}
diff --git a/src/Model/EarningRuleCondition.php b/src/Model/EarningRuleCondition.php
new file mode 100644
index 0000000..c2f6550
--- /dev/null
+++ b/src/Model/EarningRuleCondition.php
@@ -0,0 +1,52 @@
+|null */
+ protected ?array $configuration = [];
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getRule(): ?EarningRuleInterface
+ {
+ return $this->rule;
+ }
+
+ public function setRule(?EarningRuleInterface $rule): void
+ {
+ $this->rule = $rule;
+ }
+
+ public function getType(): ?string
+ {
+ return $this->type;
+ }
+
+ public function setType(?string $type): void
+ {
+ $this->type = $type;
+ }
+
+ public function getConfiguration(): array
+ {
+ return $this->configuration ?? [];
+ }
+
+ public function setConfiguration(array $configuration): void
+ {
+ $this->configuration = $configuration;
+ }
+}
diff --git a/src/Model/EarningRuleConditionInterface.php b/src/Model/EarningRuleConditionInterface.php
new file mode 100644
index 0000000..402c04c
--- /dev/null
+++ b/src/Model/EarningRuleConditionInterface.php
@@ -0,0 +1,33 @@
+
+ */
+ public function getConfiguration(): array;
+
+ /**
+ * @param array $configuration
+ */
+ public function setConfiguration(array $configuration): void;
+}
diff --git a/src/Model/EarningRuleInterface.php b/src/Model/EarningRuleInterface.php
new file mode 100644
index 0000000..17c1892
--- /dev/null
+++ b/src/Model/EarningRuleInterface.php
@@ -0,0 +1,115 @@
+
+ */
+ public function getScopeConfiguration(): array;
+
+ /**
+ * @param array $scopeConfiguration
+ */
+ public function setScopeConfiguration(array $scopeConfiguration): void;
+
+ public function getConditionsMatch(): string;
+
+ public function setConditionsMatch(string $conditionsMatch): void;
+
+ /**
+ * @return Collection
+ */
+ public function getConditions(): Collection;
+
+ public function addCondition(EarningRuleConditionInterface $condition): void;
+
+ public function removeCondition(EarningRuleConditionInterface $condition): void;
+
+ public function hasCondition(EarningRuleConditionInterface $condition): bool;
+
+ public function getAmountType(): ?string;
+
+ public function setAmountType(?string $amountType): void;
+
+ /**
+ * @return array
+ */
+ public function getAmountConfiguration(): array;
+
+ /**
+ * @param array $amountConfiguration
+ */
+ public function setAmountConfiguration(array $amountConfiguration): void;
+
+ /**
+ * Start of the rule's active window, evaluated in the application's configured timezone.
+ */
+ public function getStartsAt(): ?\DateTimeImmutable;
+
+ public function setStartsAt(?\DateTimeImmutable $startsAt): void;
+
+ public function getEndsAt(): ?\DateTimeImmutable;
+
+ public function setEndsAt(?\DateTimeImmutable $endsAt): void;
+
+ public function isStackable(): bool;
+
+ public function setStackable(bool $stackable): void;
+}
diff --git a/src/Model/ExpireLoyaltyTransaction.php b/src/Model/ExpireLoyaltyTransaction.php
new file mode 100644
index 0000000..81bcfcf
--- /dev/null
+++ b/src/Model/ExpireLoyaltyTransaction.php
@@ -0,0 +1,25 @@
+lot;
+ }
+
+ public function setLot(?CreditLoyaltyTransactionInterface $lot): void
+ {
+ $this->lot = $lot;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'expire';
+ }
+}
diff --git a/src/Model/ExpireLoyaltyTransactionInterface.php b/src/Model/ExpireLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..c302e46
--- /dev/null
+++ b/src/Model/ExpireLoyaltyTransactionInterface.php
@@ -0,0 +1,17 @@
+createdAt = new \DateTime();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getCustomer(): ?CustomerInterface
+ {
+ return $this->customer;
+ }
+
+ public function setCustomer(?CustomerInterface $customer): void
+ {
+ $this->customer = $customer;
+ }
+
+ public function getChannel(): ?ChannelInterface
+ {
+ return $this->channel;
+ }
+
+ public function setChannel(?ChannelInterface $channel): void
+ {
+ $this->channel = $channel;
+ }
+
+ public function getTier(): ?TierInterface
+ {
+ return $this->tier;
+ }
+
+ public function setTier(?TierInterface $tier): void
+ {
+ $this->tier = $tier;
+ }
+
+ public function getTierBelowThresholdSince(): ?\DateTimeImmutable
+ {
+ return $this->tierBelowThresholdSince;
+ }
+
+ public function setTierBelowThresholdSince(?\DateTimeImmutable $tierBelowThresholdSince): void
+ {
+ $this->tierBelowThresholdSince = $tierBelowThresholdSince;
+ }
+
+ public function getBalance(): int
+ {
+ return $this->balance;
+ }
+
+ public function setBalance(int $balance): void
+ {
+ $this->balance = $balance;
+ }
+
+ public function getLifetimeEarned(): int
+ {
+ return $this->lifetimeEarned;
+ }
+
+ public function setLifetimeEarned(int $lifetimeEarned): void
+ {
+ $this->lifetimeEarned = $lifetimeEarned;
+ }
+
+ public function getReferralCode(): ?string
+ {
+ return $this->referralCode;
+ }
+
+ public function setReferralCode(?string $referralCode): void
+ {
+ $this->referralCode = $referralCode;
+ }
+
+ public function getAnonymizedToken(): ?string
+ {
+ return $this->anonymizedToken;
+ }
+
+ public function setAnonymizedToken(?string $anonymizedToken): void
+ {
+ $this->anonymizedToken = $anonymizedToken;
+ }
+}
diff --git a/src/Model/LoyaltyAccountInterface.php b/src/Model/LoyaltyAccountInterface.php
new file mode 100644
index 0000000..b8279ba
--- /dev/null
+++ b/src/Model/LoyaltyAccountInterface.php
@@ -0,0 +1,61 @@
+loyaltyPointsRequested;
+ }
+
+ public function setLoyaltyPointsRequested(?int $loyaltyPointsRequested): void
+ {
+ $this->loyaltyPointsRequested = $loyaltyPointsRequested;
+ }
+}
diff --git a/src/Model/LoyaltyProgram.php b/src/Model/LoyaltyProgram.php
new file mode 100644
index 0000000..7477f30
--- /dev/null
+++ b/src/Model/LoyaltyProgram.php
@@ -0,0 +1,285 @@
+id;
+ }
+
+ public function getChannel(): ?ChannelInterface
+ {
+ return $this->channel;
+ }
+
+ public function setChannel(?ChannelInterface $channel): void
+ {
+ $this->channel = $channel;
+ }
+
+ public function getAwardOrderPointsAt(): string
+ {
+ return $this->awardOrderPointsAt;
+ }
+
+ public function setAwardOrderPointsAt(string $awardOrderPointsAt): void
+ {
+ $this->awardOrderPointsAt = $awardOrderPointsAt;
+ }
+
+ public function getEarningBasis(): string
+ {
+ return $this->earningBasis;
+ }
+
+ public function setEarningBasis(string $earningBasis): void
+ {
+ $this->earningBasis = $earningBasis;
+ }
+
+ public function isIncludeTaxes(): bool
+ {
+ return $this->includeTaxes;
+ }
+
+ public function setIncludeTaxes(bool $includeTaxes): void
+ {
+ $this->includeTaxes = $includeTaxes;
+ }
+
+ public function getRounding(): string
+ {
+ return $this->rounding;
+ }
+
+ public function setRounding(string $rounding): void
+ {
+ $this->rounding = $rounding;
+ }
+
+ public function getRedemptionConversionPoints(): int
+ {
+ return $this->redemptionConversionPoints;
+ }
+
+ public function setRedemptionConversionPoints(int $redemptionConversionPoints): void
+ {
+ $this->redemptionConversionPoints = $redemptionConversionPoints;
+ }
+
+ public function getRedemptionConversionAmount(): int
+ {
+ return $this->redemptionConversionAmount;
+ }
+
+ public function setRedemptionConversionAmount(int $redemptionConversionAmount): void
+ {
+ $this->redemptionConversionAmount = $redemptionConversionAmount;
+ }
+
+ public function getMinRedeemPoints(): int
+ {
+ return $this->minRedeemPoints;
+ }
+
+ public function setMinRedeemPoints(int $minRedeemPoints): void
+ {
+ $this->minRedeemPoints = $minRedeemPoints;
+ }
+
+ public function getMaxRedeemPercentOfOrder(): int
+ {
+ return $this->maxRedeemPercentOfOrder;
+ }
+
+ public function setMaxRedeemPercentOfOrder(int $maxRedeemPercentOfOrder): void
+ {
+ $this->maxRedeemPercentOfOrder = $maxRedeemPercentOfOrder;
+ }
+
+ public function getPointsExpiryDays(): ?int
+ {
+ return $this->pointsExpiryDays;
+ }
+
+ public function setPointsExpiryDays(?int $pointsExpiryDays): void
+ {
+ $this->pointsExpiryDays = $pointsExpiryDays;
+ }
+
+ public function getClawbackPolicy(): string
+ {
+ return $this->clawbackPolicy;
+ }
+
+ public function setClawbackPolicy(string $clawbackPolicy): void
+ {
+ $this->clawbackPolicy = $clawbackPolicy;
+ }
+
+ public function isRetroactiveGuestPoints(): bool
+ {
+ return $this->retroactiveGuestPoints;
+ }
+
+ public function setRetroactiveGuestPoints(bool $retroactiveGuestPoints): void
+ {
+ $this->retroactiveGuestPoints = $retroactiveGuestPoints;
+ }
+
+ public function getLiabilityPoints(): ?int
+ {
+ return $this->liabilityPoints;
+ }
+
+ public function setLiabilityPoints(?int $liabilityPoints): void
+ {
+ $this->liabilityPoints = $liabilityPoints;
+ }
+
+ public function getLiabilityCalculatedAt(): ?\DateTimeImmutable
+ {
+ return $this->liabilityCalculatedAt;
+ }
+
+ public function setLiabilityCalculatedAt(?\DateTimeImmutable $liabilityCalculatedAt): void
+ {
+ $this->liabilityCalculatedAt = $liabilityCalculatedAt;
+ }
+
+ public function getTierEvaluationWindow(): string
+ {
+ return $this->tierEvaluationWindow;
+ }
+
+ public function setTierEvaluationWindow(string $tierEvaluationWindow): void
+ {
+ $this->tierEvaluationWindow = $tierEvaluationWindow;
+ }
+
+ public function getTierDowngradeGraceDays(): int
+ {
+ return $this->tierDowngradeGraceDays;
+ }
+
+ public function setTierDowngradeGraceDays(int $tierDowngradeGraceDays): void
+ {
+ $this->tierDowngradeGraceDays = $tierDowngradeGraceDays;
+ }
+
+ public function getReferralReferrerPoints(): int
+ {
+ return $this->referralReferrerPoints;
+ }
+
+ public function setReferralReferrerPoints(int $referralReferrerPoints): void
+ {
+ $this->referralReferrerPoints = $referralReferrerPoints;
+ }
+
+ public function getReferralRefereePoints(): int
+ {
+ return $this->referralRefereePoints;
+ }
+
+ public function setReferralRefereePoints(int $referralRefereePoints): void
+ {
+ $this->referralRefereePoints = $referralRefereePoints;
+ }
+
+ public function getReferralMinOrderTotal(): int
+ {
+ return $this->referralMinOrderTotal;
+ }
+
+ public function setReferralMinOrderTotal(int $referralMinOrderTotal): void
+ {
+ $this->referralMinOrderTotal = $referralMinOrderTotal;
+ }
+
+ public function getReferralPendingExpiryDays(): int
+ {
+ return $this->referralPendingExpiryDays;
+ }
+
+ public function setReferralPendingExpiryDays(int $referralPendingExpiryDays): void
+ {
+ $this->referralPendingExpiryDays = $referralPendingExpiryDays;
+ }
+
+ public function isShowEarnableOnProduct(): bool
+ {
+ return $this->showEarnableOnProduct;
+ }
+
+ public function setShowEarnableOnProduct(bool $showEarnableOnProduct): void
+ {
+ $this->showEarnableOnProduct = $showEarnableOnProduct;
+ }
+
+ public function isShowEarnableInCart(): bool
+ {
+ return $this->showEarnableInCart;
+ }
+
+ public function setShowEarnableInCart(bool $showEarnableInCart): void
+ {
+ $this->showEarnableInCart = $showEarnableInCart;
+ }
+}
diff --git a/src/Model/LoyaltyProgramInterface.php b/src/Model/LoyaltyProgramInterface.php
new file mode 100644
index 0000000..68d6be0
--- /dev/null
+++ b/src/Model/LoyaltyProgramInterface.php
@@ -0,0 +1,149 @@
+ points) is deliberately not a program
+ * parameter: it is defined by earning rules.
+ */
+interface LoyaltyProgramInterface extends ResourceInterface, ChannelAwareInterface
+{
+ public const AWARD_ORDER_POINTS_AT_PAYMENT_PAID = 'payment_paid';
+
+ public const AWARD_ORDER_POINTS_AT_ORDER_FULFILLED = 'order_fulfilled';
+
+ public const EARNING_BASIS_ITEMS_TOTAL = 'items_total';
+
+ public const EARNING_BASIS_ORDER_TOTAL = 'order_total';
+
+ public const ROUNDING_FLOOR = 'floor';
+
+ public const ROUNDING_ROUND = 'round';
+
+ public const ROUNDING_CEIL = 'ceil';
+
+ public const CLAWBACK_POLICY_ALLOW_NEGATIVE = 'allow_negative';
+
+ public const CLAWBACK_POLICY_CLAMP_TO_ZERO = 'clamp_to_zero';
+
+ public const TIER_EVALUATION_WINDOW_CALENDAR_YEAR = 'calendar_year';
+
+ public const TIER_EVALUATION_WINDOW_ROLLING_12_MONTHS = 'rolling_12_months';
+
+ public const TIER_EVALUATION_WINDOW_LIFETIME = 'lifetime';
+
+ /**
+ * The single order-lifecycle moment the order pipeline fires. Deliberately one per program,
+ * never per rule: the one-earn-per-order idempotency constraint and the clawback lookup both
+ * depend on a single earn entry per order.
+ */
+ public function getAwardOrderPointsAt(): string;
+
+ public function setAwardOrderPointsAt(string $awardOrderPointsAt): void;
+
+ public function getEarningBasis(): string;
+
+ public function setEarningBasis(string $earningBasis): void;
+
+ public function isIncludeTaxes(): bool;
+
+ public function setIncludeTaxes(bool $includeTaxes): void;
+
+ public function getRounding(): string;
+
+ public function setRounding(string $rounding): void;
+
+ /**
+ * Points -> currency when spending: getRedemptionConversionPoints() points are worth
+ * getRedemptionConversionAmount() minor units.
+ */
+ public function getRedemptionConversionPoints(): int;
+
+ public function setRedemptionConversionPoints(int $redemptionConversionPoints): void;
+
+ public function getRedemptionConversionAmount(): int;
+
+ public function setRedemptionConversionAmount(int $redemptionConversionAmount): void;
+
+ public function getMinRedeemPoints(): int;
+
+ public function setMinRedeemPoints(int $minRedeemPoints): void;
+
+ /**
+ * Cap of the order items total coverable by points, in percent (0-100).
+ */
+ public function getMaxRedeemPercentOfOrder(): int;
+
+ public function setMaxRedeemPercentOfOrder(int $maxRedeemPercentOfOrder): void;
+
+ /**
+ * Null means points never expire.
+ */
+ public function getPointsExpiryDays(): ?int;
+
+ public function setPointsExpiryDays(?int $pointsExpiryDays): void;
+
+ public function getClawbackPolicy(): string;
+
+ public function setClawbackPolicy(string $clawbackPolicy): void;
+
+ /**
+ * Whether to award points for pre-registration guest orders when the guest registers.
+ */
+ public function isRetroactiveGuestPoints(): bool;
+
+ public function setRetroactiveGuestPoints(bool $retroactiveGuestPoints): void;
+
+ public function getLiabilityPoints(): ?int;
+
+ public function setLiabilityPoints(?int $liabilityPoints): void;
+
+ public function getLiabilityCalculatedAt(): ?\DateTimeImmutable;
+
+ public function setLiabilityCalculatedAt(?\DateTimeImmutable $liabilityCalculatedAt): void;
+
+ public function getTierEvaluationWindow(): string;
+
+ public function setTierEvaluationWindow(string $tierEvaluationWindow): void;
+
+ public function getTierDowngradeGraceDays(): int;
+
+ public function setTierDowngradeGraceDays(int $tierDowngradeGraceDays): void;
+
+ public function getReferralReferrerPoints(): int;
+
+ public function setReferralReferrerPoints(int $referralReferrerPoints): void;
+
+ public function getReferralRefereePoints(): int;
+
+ public function setReferralRefereePoints(int $referralRefereePoints): void;
+
+ /**
+ * Minimum items total (in minor units of the channel base currency) of the referee's first
+ * order for a referral to qualify.
+ */
+ public function getReferralMinOrderTotal(): int;
+
+ public function setReferralMinOrderTotal(int $referralMinOrderTotal): void;
+
+ public function getReferralPendingExpiryDays(): int;
+
+ public function setReferralPendingExpiryDays(int $referralPendingExpiryDays): void;
+
+ public function isShowEarnableOnProduct(): bool;
+
+ public function setShowEarnableOnProduct(bool $showEarnableOnProduct): void;
+
+ public function isShowEarnableInCart(): bool;
+
+ public function setShowEarnableInCart(bool $showEarnableInCart): void;
+}
diff --git a/src/Model/LoyaltyTransaction.php b/src/Model/LoyaltyTransaction.php
new file mode 100644
index 0000000..6545eda
--- /dev/null
+++ b/src/Model/LoyaltyTransaction.php
@@ -0,0 +1,63 @@
+occurredAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getAccount(): ?LoyaltyAccountInterface
+ {
+ return $this->account;
+ }
+
+ public function setAccount(?LoyaltyAccountInterface $account): void
+ {
+ $this->account = $account;
+ }
+
+ public function getPoints(): int
+ {
+ return $this->points;
+ }
+
+ public function setPoints(int $points): void
+ {
+ $this->points = $points;
+ }
+
+ public function getOccurredAt(): \DateTimeImmutable
+ {
+ return $this->occurredAt;
+ }
+
+ public function setOccurredAt(\DateTimeImmutable $occurredAt): void
+ {
+ $this->occurredAt = $occurredAt;
+ }
+
+ /**
+ * The Doctrine discriminator value of this transaction type. Plugin-shipped types are
+ * declared in the XML mapping; custom types registered as Sylius resources are added to
+ * the discriminator map from this value.
+ */
+ abstract public static function getDiscriminator(): string;
+}
diff --git a/src/Model/LoyaltyTransactionInterface.php b/src/Model/LoyaltyTransactionInterface.php
new file mode 100644
index 0000000..4cdd22f
--- /dev/null
+++ b/src/Model/LoyaltyTransactionInterface.php
@@ -0,0 +1,29 @@
+reason;
+ }
+
+ public function setReason(?string $reason): void
+ {
+ $this->reason = $reason;
+ }
+
+ public function getNote(): ?string
+ {
+ return $this->note;
+ }
+
+ public function setNote(?string $note): void
+ {
+ $this->note = $note;
+ }
+
+ public function getAdminUser(): ?AdminUserInterface
+ {
+ return $this->adminUser;
+ }
+
+ public function setAdminUser(?AdminUserInterface $adminUser): void
+ {
+ $this->adminUser = $adminUser;
+ }
+}
diff --git a/src/Model/RedeemLoyaltyTransaction.php b/src/Model/RedeemLoyaltyTransaction.php
new file mode 100644
index 0000000..78345b6
--- /dev/null
+++ b/src/Model/RedeemLoyaltyTransaction.php
@@ -0,0 +1,27 @@
+order;
+ }
+
+ public function setOrder(?OrderInterface $order): void
+ {
+ $this->order = $order;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'redeem';
+ }
+}
diff --git a/src/Model/RedeemLoyaltyTransactionInterface.php b/src/Model/RedeemLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..5f3ce6f
--- /dev/null
+++ b/src/Model/RedeemLoyaltyTransactionInterface.php
@@ -0,0 +1,18 @@
+redeem;
+ }
+
+ public function setRedeem(?RedeemLoyaltyTransactionInterface $redeem): void
+ {
+ $this->redeem = $redeem;
+ }
+
+ public static function getDiscriminator(): string
+ {
+ return 'redeem_rollback';
+ }
+}
diff --git a/src/Model/RedeemRollbackLoyaltyTransactionInterface.php b/src/Model/RedeemRollbackLoyaltyTransactionInterface.php
new file mode 100644
index 0000000..53eb8d7
--- /dev/null
+++ b/src/Model/RedeemRollbackLoyaltyTransactionInterface.php
@@ -0,0 +1,17 @@
+|null */
+ protected ?array $fraudFlags = null;
+
+ protected ?string $registrationIpHash = null;
+
+ public function __construct()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getReferrerAccount(): ?LoyaltyAccountInterface
+ {
+ return $this->referrerAccount;
+ }
+
+ public function setReferrerAccount(?LoyaltyAccountInterface $referrerAccount): void
+ {
+ $this->referrerAccount = $referrerAccount;
+ }
+
+ public function getRefereeCustomer(): ?CustomerInterface
+ {
+ return $this->refereeCustomer;
+ }
+
+ public function setRefereeCustomer(?CustomerInterface $refereeCustomer): void
+ {
+ $this->refereeCustomer = $refereeCustomer;
+ }
+
+ public function getChannel(): ?ChannelInterface
+ {
+ return $this->channel;
+ }
+
+ public function setChannel(?ChannelInterface $channel): void
+ {
+ $this->channel = $channel;
+ }
+
+ public function getCode(): ?string
+ {
+ return $this->code;
+ }
+
+ public function setCode(?string $code): void
+ {
+ $this->code = $code;
+ }
+
+ public function getStatus(): string
+ {
+ return $this->status;
+ }
+
+ public function setStatus(string $status): void
+ {
+ $this->status = $status;
+ }
+
+ public function getRefereeFirstOrder(): ?OrderInterface
+ {
+ return $this->refereeFirstOrder;
+ }
+
+ public function setRefereeFirstOrder(?OrderInterface $refereeFirstOrder): void
+ {
+ $this->refereeFirstOrder = $refereeFirstOrder;
+ }
+
+ public function getCreatedAt(): ?\DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function setCreatedAt(?\DateTimeImmutable $createdAt): void
+ {
+ $this->createdAt = $createdAt;
+ }
+
+ public function getQualifiedAt(): ?\DateTimeImmutable
+ {
+ return $this->qualifiedAt;
+ }
+
+ public function setQualifiedAt(?\DateTimeImmutable $qualifiedAt): void
+ {
+ $this->qualifiedAt = $qualifiedAt;
+ }
+
+ public function getFraudFlags(): array
+ {
+ return $this->fraudFlags ?? [];
+ }
+
+ public function setFraudFlags(array $fraudFlags): void
+ {
+ $this->fraudFlags = $fraudFlags;
+ }
+
+ public function getRegistrationIpHash(): ?string
+ {
+ return $this->registrationIpHash;
+ }
+
+ public function setRegistrationIpHash(?string $registrationIpHash): void
+ {
+ $this->registrationIpHash = $registrationIpHash;
+ }
+}
diff --git a/src/Model/ReferralInterface.php b/src/Model/ReferralInterface.php
new file mode 100644
index 0000000..7070104
--- /dev/null
+++ b/src/Model/ReferralInterface.php
@@ -0,0 +1,78 @@
+
+ */
+ public function getFraudFlags(): array;
+
+ /**
+ * @param list $fraudFlags
+ */
+ public function setFraudFlags(array $fraudFlags): void;
+
+ /**
+ * The salted hash of the registration IP — present only when the opt-in IP fraud check is
+ * enabled; purged after 90 days.
+ */
+ public function getRegistrationIpHash(): ?string;
+
+ public function setRegistrationIpHash(?string $registrationIpHash): void;
+}
diff --git a/src/Model/RulesBreakdownAwareInterface.php b/src/Model/RulesBreakdownAwareInterface.php
new file mode 100644
index 0000000..8145ce4
--- /dev/null
+++ b/src/Model/RulesBreakdownAwareInterface.php
@@ -0,0 +1,22 @@
+
+ */
+ public function getRulesBreakdown(): array;
+
+ /**
+ * @param array $rulesBreakdown
+ */
+ public function setRulesBreakdown(array $rulesBreakdown): void;
+}
diff --git a/src/Model/RulesBreakdownAwareTrait.php b/src/Model/RulesBreakdownAwareTrait.php
new file mode 100644
index 0000000..6b12fac
--- /dev/null
+++ b/src/Model/RulesBreakdownAwareTrait.php
@@ -0,0 +1,32 @@
+|null
+ */
+ protected ?array $rulesBreakdown = [];
+
+ /**
+ * @return array
+ */
+ public function getRulesBreakdown(): array
+ {
+ return $this->rulesBreakdown ?? [];
+ }
+
+ /**
+ * @param array $rulesBreakdown
+ */
+ public function setRulesBreakdown(array $rulesBreakdown): void
+ {
+ $this->rulesBreakdown = $rulesBreakdown;
+ }
+}
diff --git a/src/Model/Tier.php b/src/Model/Tier.php
new file mode 100644
index 0000000..8c5b603
--- /dev/null
+++ b/src/Model/Tier.php
@@ -0,0 +1,147 @@
+initializeTranslationsCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getCode(): ?string
+ {
+ return $this->code;
+ }
+
+ public function setCode(?string $code): void
+ {
+ $this->code = $code;
+ }
+
+ public function getChannel(): ?ChannelInterface
+ {
+ return $this->channel;
+ }
+
+ public function setChannel(?ChannelInterface $channel): void
+ {
+ $this->channel = $channel;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(?string $name): void
+ {
+ $this->name = $name;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): void
+ {
+ $this->position = $position;
+ }
+
+ public function getQualificationBasis(): string
+ {
+ return $this->qualificationBasis;
+ }
+
+ public function setQualificationBasis(string $qualificationBasis): void
+ {
+ $this->qualificationBasis = $qualificationBasis;
+ }
+
+ public function getThreshold(): int
+ {
+ return $this->threshold;
+ }
+
+ public function setThreshold(int $threshold): void
+ {
+ $this->threshold = $threshold;
+ }
+
+ public function getEarningMultiplier(): float
+ {
+ return $this->earningMultiplier;
+ }
+
+ public function setEarningMultiplier(float $earningMultiplier): void
+ {
+ $this->earningMultiplier = $earningMultiplier;
+ }
+
+ public function getColor(): ?string
+ {
+ return $this->color;
+ }
+
+ public function setColor(?string $color): void
+ {
+ $this->color = $color;
+ }
+
+ public function getBenefitsDescription(): ?string
+ {
+ $translation = $this->getTranslation();
+ \assert($translation instanceof TierTranslationInterface);
+
+ return $translation->getBenefitsDescription();
+ }
+
+ public function setBenefitsDescription(?string $benefitsDescription): void
+ {
+ $translation = $this->getTranslation();
+ \assert($translation instanceof TierTranslationInterface);
+
+ $translation->setBenefitsDescription($benefitsDescription);
+ }
+
+ protected function createTranslation(): TranslationInterface
+ {
+ return new TierTranslation();
+ }
+}
diff --git a/src/Model/TierInterface.php b/src/Model/TierInterface.php
new file mode 100644
index 0000000..06d64c0
--- /dev/null
+++ b/src/Model/TierInterface.php
@@ -0,0 +1,61 @@
+id;
+ }
+
+ public function getBenefitsDescription(): ?string
+ {
+ return $this->benefitsDescription;
+ }
+
+ public function setBenefitsDescription(?string $benefitsDescription): void
+ {
+ $this->benefitsDescription = $benefitsDescription;
+ }
+}
diff --git a/src/Model/TierTranslationInterface.php b/src/Model/TierTranslationInterface.php
new file mode 100644
index 0000000..9b7a1bb
--- /dev/null
+++ b/src/Model/TierTranslationInterface.php
@@ -0,0 +1,17 @@
+ $adjustmentFactory
+ */
+ public function __construct(
+ private readonly LoyaltyAccountRepositoryInterface $accountRepository,
+ private readonly LoyaltyProgramProviderInterface $programProvider,
+ private readonly PointsConverterInterface $pointsConverter,
+ private readonly AdjustmentFactoryInterface $adjustmentFactory,
+ private readonly ProportionalIntegerDistributorInterface $proportionalDistributor,
+ private readonly IntegerDistributorInterface $integerDistributor,
+ private readonly TranslatorInterface $translator,
+ ) {
+ }
+
+ public function process(BaseOrderInterface $order): void
+ {
+ Assert::isInstanceOf($order, OrderInterface::class);
+
+ if (OrderInterface::STATE_CART !== $order->getState()) {
+ return;
+ }
+
+ $order->removeAdjustmentsRecursively(LoyaltyAdjustmentTypes::REDEMPTION);
+
+ if (!$order instanceof LoyaltyOrderInterface) {
+ return;
+ }
+
+ $requested = $order->getLoyaltyPointsRequested() ?? 0;
+ if ($requested <= 0) {
+ return;
+ }
+
+ $customer = $order->getCustomer();
+ $channel = $order->getChannel();
+ if (!$customer instanceof CustomerInterface || null === $channel) {
+ return;
+ }
+
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+ if (null === $account || !$account->isEnabled()) {
+ return;
+ }
+
+ $program = $this->programProvider->getByChannel($channel);
+
+ $applied = $this->appliedPoints($requested, $account->getBalance(), $order, $program);
+ if ($applied <= 0) {
+ return;
+ }
+
+ $this->distribute($order, -$this->pointsConverter->amountFromPoints($applied, $program), $applied);
+ }
+
+ private function appliedPoints(int $requested, int $balance, OrderInterface $order, LoyaltyProgramInterface $program): int
+ {
+ $capAmount = (int) floor($order->getItemsTotal() * $program->getMaxRedeemPercentOfOrder() / 100);
+ $capPoints = $this->pointsConverter->pointsFromAmount($capAmount, $program);
+
+ return $this->pointsConverter->clampToCleanMultiple(min($requested, $balance, $capPoints), $program);
+ }
+
+ /**
+ * Distributes the discount across the order items' units following Sylius' order-promotion
+ * pattern, which is what makes VAT calculate on the net amount natively.
+ */
+ private function distribute(OrderInterface $order, int $discount, int $appliedPoints): void
+ {
+ $items = [];
+ $itemTotals = [];
+ foreach ($order->getItems() as $item) {
+ if (!$item instanceof OrderItemInterface) {
+ continue;
+ }
+
+ $items[] = $item;
+ $itemTotals[] = $item->getTotal();
+ }
+
+ if ([] === $items) {
+ return;
+ }
+
+ $label = $this->translator->trans('setono_sylius_loyalty.ui.points_redemption');
+
+ $itemShares = $this->proportionalDistributor->distribute($itemTotals, $discount);
+ foreach ($items as $index => $item) {
+ $itemShare = $itemShares[$index] ?? 0;
+ if (!is_int($itemShare) || 0 === $itemShare) {
+ continue;
+ }
+
+ $unitShares = $this->integerDistributor->distribute($itemShare, $item->getQuantity());
+ $unitIndex = 0;
+ foreach ($item->getUnits() as $unit) {
+ $amount = $unitShares[$unitIndex] ?? 0;
+ ++$unitIndex;
+ if (!is_int($amount) || 0 === $amount) {
+ continue;
+ }
+
+ $adjustment = $this->adjustmentFactory->createWithData(
+ LoyaltyAdjustmentTypes::REDEMPTION,
+ $label,
+ $amount,
+ );
+ $adjustment->setDetails([
+ 'appliedPoints' => $appliedPoints,
+ ]);
+
+ $unit->addAdjustment($adjustment);
+ }
+ }
+ }
+}
diff --git a/src/Promotion/Checker/Rule/CustomerLoyaltyTierRuleChecker.php b/src/Promotion/Checker/Rule/CustomerLoyaltyTierRuleChecker.php
new file mode 100644
index 0000000..d164f00
--- /dev/null
+++ b/src/Promotion/Checker/Rule/CustomerLoyaltyTierRuleChecker.php
@@ -0,0 +1,67 @@
+ $configuration
+ */
+ public function isEligible(PromotionSubjectInterface $subject, array $configuration): bool
+ {
+ if (!$subject instanceof OrderInterface) {
+ return false;
+ }
+
+ $customer = $subject->getCustomer();
+ $channel = $subject->getChannel();
+ if (!$customer instanceof CustomerInterface || !$channel instanceof ChannelInterface) {
+ return false;
+ }
+
+ $requiredCode = $configuration['tier'] ?? null;
+ if (!is_string($requiredCode) || '' === $requiredCode) {
+ return false;
+ }
+
+ $required = $this->tierRepository->findOneBy(['code' => $requiredCode, 'channel' => $channel]);
+ if (!$required instanceof TierInterface) {
+ return false;
+ }
+
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+ if (null === $account || !$account->isEnabled()) {
+ return false;
+ }
+
+ $tier = $account->getTier();
+ if (null === $tier) {
+ return false;
+ }
+
+ return $tier->getPosition() >= $required->getPosition();
+ }
+}
diff --git a/src/Provider/Admin/DashboardStatsProvider.php b/src/Provider/Admin/DashboardStatsProvider.php
new file mode 100644
index 0000000..aa1dc8a
--- /dev/null
+++ b/src/Provider/Admin/DashboardStatsProvider.php
@@ -0,0 +1,116 @@
+entityManager->createQueryBuilder()
+ ->select('COUNT(a.id)')
+ ->from($this->accountClass, 'a')
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $earned = (int) $this->entityManager->createQueryBuilder()
+ ->select('COALESCE(SUM(c.points), 0)')
+ ->from(CreditLoyaltyTransaction::class, 'c')
+ ->andWhere('c.occurredAt >= :since')
+ ->setParameter('since', $since)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $redeemed = (int) $this->entityManager->createQueryBuilder()
+ ->select('COALESCE(SUM(-r.points), 0)')
+ ->from(RedeemLoyaltyTransaction::class, 'r')
+ ->andWhere('r.occurredAt >= :since')
+ ->setParameter('since', $since)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $earned90 = (int) $this->entityManager->createQueryBuilder()
+ ->select('COALESCE(SUM(c.points), 0)')
+ ->from(CreditLoyaltyTransaction::class, 'c')
+ ->andWhere('c.occurredAt >= :since')
+ ->andWhere('c NOT INSTANCE OF :rollback')
+ ->setParameter('since', $quarter)
+ ->setParameter('rollback', $this->entityManager->getClassMetadata(RedeemRollbackLoyaltyTransaction::class))
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $redeemed90 = (int) $this->entityManager->createQueryBuilder()
+ ->select('COALESCE(SUM(-r.points), 0)')
+ ->from(RedeemLoyaltyTransaction::class, 'r')
+ ->andWhere('r.occurredAt >= :since')
+ ->setParameter('since', $quarter)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $activeAccounts = (int) $this->entityManager->createQueryBuilder()
+ ->select('COUNT(DISTINCT IDENTITY(t.account))')
+ ->from($this->transactionClass, 't')
+ ->andWhere('t.occurredAt >= :since')
+ ->setParameter('since', $quarter)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $liability = null;
+ $liabilityCalculatedAt = null;
+ /** @var list $programs */
+ $programs = $this->entityManager->getRepository($this->programClass)->findAll();
+ foreach ($programs as $program) {
+ if (null !== $program->getLiabilityPoints()) {
+ $liability = ($liability ?? 0) + $program->getLiabilityPoints();
+ $calculatedAt = $program->getLiabilityCalculatedAt();
+ if (null === $liabilityCalculatedAt || ($calculatedAt !== null && $calculatedAt < $liabilityCalculatedAt)) {
+ $liabilityCalculatedAt = $calculatedAt;
+ }
+ }
+ }
+
+ return [
+ 'accounts' => $accounts,
+ 'earnedLast30Days' => $earned,
+ 'redeemedLast30Days' => $redeemed,
+ 'activeAccounts90Days' => $activeAccounts,
+ 'redemptionRate90Days' => $earned90 > 0 ? round($redeemed90 / $earned90 * 100, 1) : null,
+ 'liabilityPoints' => $liability,
+ 'liabilityCalculatedAt' => $liabilityCalculatedAt,
+ ];
+ }
+}
diff --git a/src/Provider/Admin/DashboardStatsProviderInterface.php b/src/Provider/Admin/DashboardStatsProviderInterface.php
new file mode 100644
index 0000000..bbf7aec
--- /dev/null
+++ b/src/Provider/Admin/DashboardStatsProviderInterface.php
@@ -0,0 +1,13 @@
+ $accountFactory
+ */
+ public function __construct(
+ private readonly LoyaltyAccountRepositoryInterface $accountRepository,
+ private readonly FactoryInterface $accountFactory,
+ private readonly ManagerRegistry $managerRegistry,
+ ) {
+ }
+
+ public function getByCustomerAndChannel(CustomerInterface $customer, ChannelInterface $channel): LoyaltyAccountInterface
+ {
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+ if (null !== $account) {
+ return $account;
+ }
+
+ $account = $this->accountFactory->createNew();
+ Assert::isInstanceOf($account, LoyaltyAccountInterface::class);
+ $account->setCustomer($customer);
+ $account->setChannel($channel);
+
+ $manager = $this->managerRegistry->getManagerForClass($account::class);
+ Assert::notNull($manager);
+
+ try {
+ $manager->persist($account);
+ $manager->flush();
+ } catch (UniqueConstraintViolationException) {
+ // Another process created the account concurrently. The entity manager is closed
+ // by the failed flush, so reset it and load the winning row.
+ $this->managerRegistry->resetManager();
+
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+ Assert::notNull($account);
+ }
+
+ return $account;
+ }
+}
diff --git a/src/Provider/LoyaltyAccountProviderInterface.php b/src/Provider/LoyaltyAccountProviderInterface.php
new file mode 100644
index 0000000..bd5da25
--- /dev/null
+++ b/src/Provider/LoyaltyAccountProviderInterface.php
@@ -0,0 +1,18 @@
+ $programFactory
+ */
+ public function __construct(
+ private readonly LoyaltyProgramRepositoryInterface $programRepository,
+ private readonly FactoryInterface $programFactory,
+ private readonly ManagerRegistry $managerRegistry,
+ ) {
+ }
+
+ public function getByChannel(ChannelInterface $channel): LoyaltyProgramInterface
+ {
+ $program = $this->programRepository->findOneByChannel($channel);
+ if (null !== $program) {
+ return $program;
+ }
+
+ $program = $this->programFactory->createNew();
+ Assert::isInstanceOf($program, LoyaltyProgramInterface::class);
+ $program->setChannel($channel);
+
+ $manager = $this->managerRegistry->getManagerForClass($program::class);
+ Assert::notNull($manager);
+
+ try {
+ $manager->persist($program);
+ $manager->flush();
+ } catch (UniqueConstraintViolationException) {
+ // Another process created the program concurrently. The entity manager is closed
+ // by the failed flush, so reset it and load the winning row.
+ $this->managerRegistry->resetManager();
+
+ $program = $this->programRepository->findOneByChannel($channel);
+ Assert::notNull($program);
+ }
+
+ return $program;
+ }
+}
diff --git a/src/Provider/LoyaltyProgramProviderInterface.php b/src/Provider/LoyaltyProgramProviderInterface.php
new file mode 100644
index 0000000..295b016
--- /dev/null
+++ b/src/Provider/LoyaltyProgramProviderInterface.php
@@ -0,0 +1,16 @@
+ $presets preset step buttons: points and
+ * the resulting discount in minor units (the only place a currency equivalent of
+ * points is shown)
+ */
+ public function __construct(
+ public readonly int $balance,
+ public readonly int $minRedeemPoints,
+ public readonly array $presets,
+ public readonly int $requestedPoints,
+ public readonly int $appliedPoints,
+ public readonly int $appliedAmount,
+ ) {
+ }
+
+ public function canRedeem(): bool
+ {
+ return $this->balance >= $this->minRedeemPoints;
+ }
+
+ public function isClamped(): bool
+ {
+ return $this->appliedPoints > 0 && $this->appliedPoints < $this->requestedPoints;
+ }
+}
diff --git a/src/Provider/Shop/CartRedemptionViewProvider.php b/src/Provider/Shop/CartRedemptionViewProvider.php
new file mode 100644
index 0000000..cc4245e
--- /dev/null
+++ b/src/Provider/Shop/CartRedemptionViewProvider.php
@@ -0,0 +1,101 @@
+getCustomer();
+ $channel = $cart->getChannel();
+ if (!$customer instanceof CustomerInterface || null === $channel) {
+ return null;
+ }
+
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+ if (null === $account || !$account->isEnabled()) {
+ return null;
+ }
+
+ $program = $this->programProvider->getByChannel($channel);
+ $appliedPoints = $this->appliedPointsProvider->getAppliedPoints($cart);
+
+ if ($account->getBalance() < $program->getMinRedeemPoints() && $appliedPoints <= 0) {
+ return null;
+ }
+
+ return new CartRedemptionView(
+ $account->getBalance(),
+ $program->getMinRedeemPoints(),
+ $this->presets($cart, $account->getBalance(), $program),
+ $cart->getLoyaltyPointsRequested() ?? 0,
+ $appliedPoints,
+ $this->pointsConverter->amountFromPoints($appliedPoints, $program),
+ );
+ }
+
+ /**
+ * Up to three preset steps between the redemption minimum and the currently usable
+ * maximum, each a clean multiple of the conversion so it maps to a clean currency amount.
+ *
+ * @return list
+ */
+ private function presets(OrderInterface $cart, int $balance, LoyaltyProgramInterface $program): array
+ {
+ $capPoints = $this->pointsConverter->pointsFromAmount(
+ (int) floor($cart->getItemsTotal() * $program->getMaxRedeemPercentOfOrder() / 100),
+ $program,
+ );
+
+ $maxUsable = $this->pointsConverter->clampToCleanMultiple(min($balance, $capPoints), $program);
+
+ $pointsUnit = max(1, $program->getRedemptionConversionPoints());
+ $minimum = $program->getMinRedeemPoints();
+ $minimum += ($pointsUnit - $minimum % $pointsUnit) % $pointsUnit;
+
+ $candidates = [
+ $minimum,
+ $this->pointsConverter->clampToCleanMultiple(intdiv($maxUsable, 2), $program),
+ $this->pointsConverter->clampToCleanMultiple(intdiv($maxUsable * 3, 4), $program),
+ ];
+
+ $presets = [];
+ foreach ($candidates as $points) {
+ if ($points < $minimum || $points > $maxUsable || $points > $balance) {
+ continue;
+ }
+
+ $presets[$points] = [
+ 'points' => $points,
+ 'amount' => $this->pointsConverter->amountFromPoints($points, $program),
+ ];
+ }
+
+ ksort($presets);
+
+ return array_values($presets);
+ }
+}
diff --git a/src/Provider/Shop/CartRedemptionViewProviderInterface.php b/src/Provider/Shop/CartRedemptionViewProviderInterface.php
new file mode 100644
index 0000000..a9dbde8
--- /dev/null
+++ b/src/Provider/Shop/CartRedemptionViewProviderInterface.php
@@ -0,0 +1,16 @@
+threshold <= 0) {
+ return 0;
+ }
+
+ return (int) min(100, floor($this->metric / $this->threshold * 100));
+ }
+
+ public function getRemaining(): int
+ {
+ return max(0, $this->threshold - $this->metric);
+ }
+}
diff --git a/src/Provider/Shop/TierProgressProvider.php b/src/Provider/Shop/TierProgressProvider.php
new file mode 100644
index 0000000..1eaec3e
--- /dev/null
+++ b/src/Provider/Shop/TierProgressProvider.php
@@ -0,0 +1,58 @@
+getChannel();
+ if (!$channel instanceof ChannelInterface) {
+ return null;
+ }
+
+ $tiers = $this->tierRepository->findQualifiable($channel);
+ if ([] === $tiers) {
+ return null;
+ }
+
+ $current = $account->getTier();
+ $currentPosition = null === $current ? \PHP_INT_MIN : $current->getPosition();
+
+ // The next tier is the lowest-positioned tier above the current one
+ $next = null;
+ foreach ($tiers as $tier) {
+ if ($tier->getPosition() > $currentPosition) {
+ $next = $tier;
+ } else {
+ break;
+ }
+ }
+
+ if (null === $next) {
+ return new TierProgress($current, null, 0, 0, true);
+ }
+
+ $window = $this->windowResolver->resolve($this->programProvider->getByChannel($channel));
+ $metric = $this->basisRegistry->get($next->getQualificationBasis())->calculate($account, $window);
+
+ return new TierProgress($current, $next, $metric, $next->getThreshold(), false);
+ }
+}
diff --git a/src/Provider/Shop/TierProgressProviderInterface.php b/src/Provider/Shop/TierProgressProviderInterface.php
new file mode 100644
index 0000000..38af538
--- /dev/null
+++ b/src/Provider/Shop/TierProgressProviderInterface.php
@@ -0,0 +1,15 @@
+getAdjustmentsTotalRecursively(LoyaltyAdjustmentTypes::REDEMPTION);
+ if ($discount <= 0) {
+ return 0;
+ }
+
+ $channel = $order->getChannel();
+ if (null === $channel) {
+ return 0;
+ }
+
+ return $this->pointsFromDiscount($discount, $this->programProvider->getByChannel($channel));
+ }
+
+ /**
+ * The discount is a clean multiple of the conversion amount by construction, so this is
+ * exact: discount / A * P.
+ */
+ private function pointsFromDiscount(int $discount, LoyaltyProgramInterface $program): int
+ {
+ $amountUnit = max(1, $program->getRedemptionConversionAmount());
+
+ return intdiv($discount, $amountUnit) * max(1, $program->getRedemptionConversionPoints());
+ }
+}
diff --git a/src/Redemption/AppliedPointsProviderInterface.php b/src/Redemption/AppliedPointsProviderInterface.php
new file mode 100644
index 0000000..dced5b2
--- /dev/null
+++ b/src/Redemption/AppliedPointsProviderInterface.php
@@ -0,0 +1,17 @@
+getRedemptionConversionPoints());
+
+ return intdiv($points, $pointsUnit) * $program->getRedemptionConversionAmount();
+ }
+
+ public function pointsFromAmount(int $amount, LoyaltyProgramInterface $program): int
+ {
+ $amountUnit = max(1, $program->getRedemptionConversionAmount());
+
+ return intdiv($amount, $amountUnit) * max(1, $program->getRedemptionConversionPoints());
+ }
+
+ public function clampToCleanMultiple(int $points, LoyaltyProgramInterface $program): int
+ {
+ $pointsUnit = max(1, $program->getRedemptionConversionPoints());
+
+ return $points - ($points % $pointsUnit);
+ }
+}
diff --git a/src/Redemption/PointsConverterInterface.php b/src/Redemption/PointsConverterInterface.php
new file mode 100644
index 0000000..d6aae26
--- /dev/null
+++ b/src/Redemption/PointsConverterInterface.php
@@ -0,0 +1,32 @@
+withValue($code)
+ ->withExpires(new \DateTimeImmutable(sprintf('+%d days', self::TTL_DAYS)))
+ ->withHttpOnly(true)
+ ->withSameSite(Cookie::SAMESITE_LAX)
+ ;
+ }
+
+ /**
+ * A cheap format check before any database lookup: 8 chars of Crockford base32.
+ */
+ public static function isValidFormat(string $code): bool
+ {
+ return 1 === preg_match('/^[0-9A-HJKMNP-TV-Z]{8}$/', $code);
+ }
+}
diff --git a/src/Referral/FraudCheck/AccountAgeCheck.php b/src/Referral/FraudCheck/AccountAgeCheck.php
new file mode 100644
index 0000000..788491a
--- /dev/null
+++ b/src/Referral/FraudCheck/AccountAgeCheck.php
@@ -0,0 +1,34 @@
+getRefereeCustomer();
+ $capturedAt = $referral->getCreatedAt();
+ if (!$referee instanceof CustomerInterface || null === $capturedAt || null === $referee->getCreatedAt()) {
+ return null;
+ }
+
+ // A small tolerance: registration and referral creation happen in the same request
+ $registeredAt = \DateTimeImmutable::createFromInterface($referee->getCreatedAt());
+ if ($registeredAt < $capturedAt->modify('-5 minutes')) {
+ return new FraudFlag('account_age', 'The referee account existed before the referral was captured');
+ }
+
+ return null;
+ }
+}
diff --git a/src/Referral/FraudCheck/CompositeReferralFraudCheck.php b/src/Referral/FraudCheck/CompositeReferralFraudCheck.php
new file mode 100644
index 0000000..9550e64
--- /dev/null
+++ b/src/Referral/FraudCheck/CompositeReferralFraudCheck.php
@@ -0,0 +1,28 @@
+
+ */
+final class CompositeReferralFraudCheck extends CompositeService implements CompositeReferralFraudCheckInterface
+{
+ public function checkAll(ReferralInterface $referral, OrderInterface $order): array
+ {
+ $flags = [];
+ foreach ($this->services as $check) {
+ $flag = $check->check($referral, $order);
+ if (null !== $flag) {
+ $flags[] = $flag;
+ }
+ }
+
+ return $flags;
+ }
+}
diff --git a/src/Referral/FraudCheck/CompositeReferralFraudCheckInterface.php b/src/Referral/FraudCheck/CompositeReferralFraudCheckInterface.php
new file mode 100644
index 0000000..6a50cb0
--- /dev/null
+++ b/src/Referral/FraudCheck/CompositeReferralFraudCheckInterface.php
@@ -0,0 +1,16 @@
+
+ */
+ public function checkAll(ReferralInterface $referral, OrderInterface $order): array;
+}
diff --git a/src/Referral/FraudCheck/FraudFlag.php b/src/Referral/FraudCheck/FraudFlag.php
new file mode 100644
index 0000000..4daa3f1
--- /dev/null
+++ b/src/Referral/FraudCheck/FraudFlag.php
@@ -0,0 +1,22 @@
+ $this->check, 'detail' => $this->detail];
+ }
+}
diff --git a/src/Referral/FraudCheck/ReferralFraudCheckInterface.php b/src/Referral/FraudCheck/ReferralFraudCheckInterface.php
new file mode 100644
index 0000000..a408084
--- /dev/null
+++ b/src/Referral/FraudCheck/ReferralFraudCheckInterface.php
@@ -0,0 +1,18 @@
+getRegistrationIpHash();
+ if (!$this->enabled || null === $hash) {
+ return null;
+ }
+
+ $referrerAccount = $referral->getReferrerAccount();
+ if (!$referrerAccount instanceof LoyaltyAccountInterface) {
+ return null;
+ }
+
+ /** @var list $siblings */
+ $siblings = $this->referralRepository->findBy(['referrerAccount' => $referrerAccount, 'registrationIpHash' => $hash]);
+ foreach ($siblings as $sibling) {
+ if ($sibling->getId() !== $referral->getId()) {
+ return new FraudFlag('registration_ip', 'Another referee of the same referrer registered from the same IP');
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Referral/FraudCheck/RewardCapCheck.php b/src/Referral/FraudCheck/RewardCapCheck.php
new file mode 100644
index 0000000..eb5fb1d
--- /dev/null
+++ b/src/Referral/FraudCheck/RewardCapCheck.php
@@ -0,0 +1,38 @@
+getReferrerAccount();
+ if (!$referrerAccount instanceof LoyaltyAccountInterface) {
+ return null;
+ }
+
+ $rewarded = $this->referralRepository->countRewardedSince($referrerAccount, new \DateTimeImmutable('-30 days'));
+ if ($rewarded >= $this->cap) {
+ return new FraudFlag('reward_cap', sprintf('The referrer already has %d rewarded referrals in 30 days (cap %d)', $rewarded, $this->cap));
+ }
+
+ return null;
+ }
+}
diff --git a/src/Referral/FraudCheck/SelfReferralCheck.php b/src/Referral/FraudCheck/SelfReferralCheck.php
new file mode 100644
index 0000000..1ca9327
--- /dev/null
+++ b/src/Referral/FraudCheck/SelfReferralCheck.php
@@ -0,0 +1,66 @@
+getReferrerAccount()?->getCustomer();
+ $referee = $referral->getRefereeCustomer();
+ if (!$referrer instanceof CustomerInterface || !$referee instanceof CustomerInterface) {
+ return null;
+ }
+
+ if ($referrer === $referee || $referrer->getId() === $referee->getId()) {
+ return new FraudFlag('self_referral', 'Referrer and referee are the same customer');
+ }
+
+ if (null !== $referrer->getEmail() && null !== $referee->getEmail() &&
+ self::normalizeEmail($referrer->getEmail()) === self::normalizeEmail($referee->getEmail())) {
+ return new FraudFlag('self_referral', 'Referrer and referee emails normalize identically');
+ }
+
+ $referrerAddress = $referrer->getDefaultAddress();
+ $refereeAddress = $referee->getDefaultAddress() ?? $order->getShippingAddress();
+ if (null !== $referrerAddress && null !== $refereeAddress &&
+ self::fuzzyAddress($referrerAddress) === self::fuzzyAddress($refereeAddress) &&
+ '' !== self::fuzzyAddress($referrerAddress)) {
+ return new FraudFlag('self_referral', 'Referrer and referee share an address');
+ }
+
+ return null;
+ }
+
+ private static function normalizeEmail(string $email): string
+ {
+ $email = mb_strtolower(trim($email));
+ [$local, $domain] = explode('@', $email, 2) + [1 => ''];
+
+ // Strip +alias and dots in the local part (Gmail-style aliasing)
+ $local = explode('+', $local, 2)[0];
+ $local = str_replace('.', '', $local);
+
+ return $local . '@' . $domain;
+ }
+
+ private static function fuzzyAddress(AddressInterface $address): string
+ {
+ $street = mb_strtolower(preg_replace('/\s+/', '', (string) $address->getStreet()) ?? '');
+ $postcode = mb_strtolower(preg_replace('/\s+/', '', (string) $address->getPostcode()) ?? '');
+
+ return '' === $street . $postcode ? '' : $street . '|' . $postcode;
+ }
+}
diff --git a/src/Referral/ReferralCodeGenerator.php b/src/Referral/ReferralCodeGenerator.php
new file mode 100644
index 0000000..8198bac
--- /dev/null
+++ b/src/Referral/ReferralCodeGenerator.php
@@ -0,0 +1,56 @@
+ $accountClass
+ */
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly string $accountClass,
+ ) {
+ }
+
+ public function getCode(LoyaltyAccountInterface $account): string
+ {
+ $code = $account->getReferralCode();
+ if (null !== $code) {
+ return $code;
+ }
+
+ $repository = $this->entityManager->getRepository($this->accountClass);
+ do {
+ $code = self::generate();
+ } while (null !== $repository->findOneBy(['referralCode' => $code]));
+
+ $account->setReferralCode($code);
+ $this->entityManager->flush();
+
+ return $code;
+ }
+
+ private static function generate(): string
+ {
+ $code = '';
+ for ($i = 0; $i < self::LENGTH; ++$i) {
+ $code .= self::ALPHABET[random_int(0, 31)];
+ }
+
+ return $code;
+ }
+}
diff --git a/src/Referral/ReferralCodeGeneratorInterface.php b/src/Referral/ReferralCodeGeneratorInterface.php
new file mode 100644
index 0000000..d8762c6
--- /dev/null
+++ b/src/Referral/ReferralCodeGeneratorInterface.php
@@ -0,0 +1,15 @@
+getCustomer();
+ $channel = $order->getChannel();
+ if (!$customer instanceof CustomerInterface || !$channel instanceof ChannelInterface) {
+ return;
+ }
+
+ $referral = $this->referralRepository->findOneByRefereeAndChannel($customer, $channel);
+ if (null === $referral || ReferralInterface::STATUS_PENDING !== $referral->getStatus()) {
+ return;
+ }
+
+ // Only orders placed after attribution count; a pre-registration guest order never
+ // qualifies (deliberately decoupled from retroactive guest points)
+ $placedAt = $order->getCheckoutCompletedAt();
+ $capturedAt = $referral->getCreatedAt();
+ if (null === $placedAt || null === $capturedAt || $placedAt < $capturedAt) {
+ return;
+ }
+
+ // The FIRST order to reach the award moment decides — later orders never re-qualify
+ if (null === $referral->getRefereeFirstOrder()) {
+ $referral->setRefereeFirstOrder($order);
+ } elseif ($referral->getRefereeFirstOrder() !== $order) {
+ return;
+ }
+
+ $program = $this->programProvider->getByChannel($channel);
+
+ if ($order->getItemsTotal() < $program->getReferralMinOrderTotal()) {
+ // Below the minimum: the decision stands (stays pending until expiry)
+ $this->entityManager->flush();
+
+ return;
+ }
+
+ $flags = $this->fraudCheck->checkAll($referral, $order);
+ if ([] !== $flags) {
+ $referral->setStatus(ReferralInterface::STATUS_REJECTED);
+ $referral->setFraudFlags(array_map(
+ static fn ($flag) => $flag->toArray(),
+ $flags,
+ ));
+ $this->entityManager->flush();
+
+ $this->logger->info(sprintf(
+ '[Loyalty] Referral %d rejected by fraud checks: %s',
+ (int) $referral->getId(),
+ implode(', ', array_map(static fn ($flag) => $flag->check, $flags)),
+ ));
+
+ return;
+ }
+
+ $this->requalify($referral);
+ }
+
+ public function requalify(ReferralInterface $referral): void
+ {
+ $channel = $referral->getChannel();
+ if (!$channel instanceof ChannelInterface) {
+ return;
+ }
+
+ $referral->setStatus(ReferralInterface::STATUS_QUALIFIED);
+ $referral->setQualifiedAt(new \DateTimeImmutable());
+ $this->entityManager->flush();
+
+ $this->reward($referral, $this->programProvider->getByChannel($channel), $channel);
+ }
+
+ private function reward(ReferralInterface $referral, LoyaltyProgramInterface $program, ChannelInterface $channel): void
+ {
+ $expiresAt = null;
+ if (null !== $program->getPointsExpiryDays()) {
+ $expiresAt = new \DateTimeImmutable(sprintf('+%d days', $program->getPointsExpiryDays()));
+ }
+
+ $referrerAccount = $referral->getReferrerAccount();
+ if (null !== $referrerAccount && $referrerAccount->isEnabled() && $program->getReferralReferrerPoints() > 0) {
+ $this->ledger->earnReferral($referrerAccount, $program->getReferralReferrerPoints(), $referral, $expiresAt);
+ }
+
+ $referee = $referral->getRefereeCustomer();
+ if ($referee instanceof CustomerInterface && $program->getReferralRefereePoints() > 0) {
+ $refereeAccount = $this->accountProvider->getByCustomerAndChannel($referee, $channel);
+ if ($refereeAccount->isEnabled()) {
+ $this->ledger->earnReferral($refereeAccount, $program->getReferralRefereePoints(), $referral, $expiresAt);
+ }
+ }
+
+ $referral->setStatus(ReferralInterface::STATUS_REWARDED);
+ $this->entityManager->flush();
+ }
+}
diff --git a/src/Referral/ReferralQualifierInterface.php b/src/Referral/ReferralQualifierInterface.php
new file mode 100644
index 0000000..f6bff68
--- /dev/null
+++ b/src/Referral/ReferralQualifierInterface.php
@@ -0,0 +1,22 @@
+ $rules */
+ $rules = $this->createQueryBuilder('r')
+ ->andWhere('r.channel = :channel')
+ ->andWhere('r.trigger = :trigger')
+ ->andWhere('r.enabled = true')
+ ->setParameter('channel', $channel)
+ ->setParameter('trigger', $trigger)
+ ->orderBy('r.priority', 'DESC')
+ ->addOrderBy('r.id', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $rules;
+ }
+}
diff --git a/src/Repository/EarningRuleRepositoryInterface.php b/src/Repository/EarningRuleRepositoryInterface.php
new file mode 100644
index 0000000..cb0b17f
--- /dev/null
+++ b/src/Repository/EarningRuleRepositoryInterface.php
@@ -0,0 +1,23 @@
+
+ */
+interface EarningRuleRepositoryInterface extends RepositoryInterface
+{
+ /**
+ * Returns the channel's enabled rules for the given trigger. The active window is checked
+ * by the evaluator (against the possibly overridden evaluation time), not here.
+ *
+ * @return list
+ */
+ public function findForEvaluation(ChannelInterface $channel, string $trigger): array;
+}
diff --git a/src/Repository/LoyaltyAccountRepository.php b/src/Repository/LoyaltyAccountRepository.php
new file mode 100644
index 0000000..196e578
--- /dev/null
+++ b/src/Repository/LoyaltyAccountRepository.php
@@ -0,0 +1,26 @@
+findOneBy([
+ 'customer' => $customer,
+ 'channel' => $channel,
+ ]);
+
+ Assert::nullOrIsInstanceOf($account, LoyaltyAccountInterface::class);
+
+ return $account;
+ }
+}
diff --git a/src/Repository/LoyaltyAccountRepositoryInterface.php b/src/Repository/LoyaltyAccountRepositoryInterface.php
new file mode 100644
index 0000000..51f4b8d
--- /dev/null
+++ b/src/Repository/LoyaltyAccountRepositoryInterface.php
@@ -0,0 +1,18 @@
+
+ */
+interface LoyaltyAccountRepositoryInterface extends RepositoryInterface
+{
+ public function findOneByCustomerAndChannel(CustomerInterface $customer, ChannelInterface $channel): ?LoyaltyAccountInterface;
+}
diff --git a/src/Repository/LoyaltyProgramRepository.php b/src/Repository/LoyaltyProgramRepository.php
new file mode 100644
index 0000000..f246c64
--- /dev/null
+++ b/src/Repository/LoyaltyProgramRepository.php
@@ -0,0 +1,24 @@
+findOneBy([
+ 'channel' => $channel,
+ ]);
+
+ Assert::nullOrIsInstanceOf($program, LoyaltyProgramInterface::class);
+
+ return $program;
+ }
+}
diff --git a/src/Repository/LoyaltyProgramRepositoryInterface.php b/src/Repository/LoyaltyProgramRepositoryInterface.php
new file mode 100644
index 0000000..c553e9b
--- /dev/null
+++ b/src/Repository/LoyaltyProgramRepositoryInterface.php
@@ -0,0 +1,17 @@
+
+ */
+interface LoyaltyProgramRepositoryInterface extends RepositoryInterface
+{
+ public function findOneByChannel(ChannelInterface $channel): ?LoyaltyProgramInterface;
+}
diff --git a/src/Repository/LoyaltyTransactionRepository.php b/src/Repository/LoyaltyTransactionRepository.php
new file mode 100644
index 0000000..e5120c3
--- /dev/null
+++ b/src/Repository/LoyaltyTransactionRepository.php
@@ -0,0 +1,186 @@
+ $transactions */
+ $transactions = $this->createQueryBuilder('t')
+ ->andWhere('t.account = :account')
+ ->setParameter('account', $account)
+ ->orderBy('t.occurredAt', 'ASC')
+ ->addOrderBy('t.id', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $transactions;
+ }
+
+ public function findEarnOrderTransaction(OrderInterface $order): ?EarnOrderLoyaltyTransactionInterface
+ {
+ $transaction = $this->getEntityManager()->createQueryBuilder()
+ ->select('t')
+ ->from(EarnOrderLoyaltyTransaction::class, 't')
+ ->andWhere('t.order = :order')
+ ->setParameter('order', $order)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ Assert::nullOrIsInstanceOf($transaction, EarnOrderLoyaltyTransactionInterface::class);
+
+ return $transaction;
+ }
+
+ public function findRedeemTransaction(OrderInterface $order): ?RedeemLoyaltyTransactionInterface
+ {
+ $transaction = $this->getEntityManager()->createQueryBuilder()
+ ->select('t')
+ ->from(RedeemLoyaltyTransaction::class, 't')
+ ->andWhere('t.order = :order')
+ ->setParameter('order', $order)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ Assert::nullOrIsInstanceOf($transaction, RedeemLoyaltyTransactionInterface::class);
+
+ return $transaction;
+ }
+
+ public function hasRollback(RedeemLoyaltyTransactionInterface $redeem): bool
+ {
+ return null !== $this->getEntityManager()->createQueryBuilder()
+ ->select('r.id')
+ ->from(RedeemRollbackLoyaltyTransaction::class, 'r')
+ ->andWhere('r.redeem = :redeem')
+ ->setParameter('redeem', $redeem)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ }
+
+ public function findAccountIdsWithExpiredOpenLots(\DateTimeImmutable $now, int $limit, int $offset = 0): array
+ {
+ /** @var list $rows */
+ $rows = $this->getEntityManager()->createQueryBuilder()
+ ->select('IDENTITY(c.account) AS accountId')
+ ->distinct()
+ ->from(CreditLoyaltyTransaction::class, 'c')
+ ->andWhere('c.expiresAt IS NOT NULL')
+ ->andWhere('c.expiresAt < :now')
+ ->andWhere(sprintf(
+ 'NOT EXISTS (SELECT 1 FROM %s e WHERE e.lot = c)',
+ ExpireLoyaltyTransaction::class,
+ ))
+ ->setParameter('now', $now)
+ ->orderBy('accountId', 'ASC')
+ ->setMaxResults($limit)
+ ->setFirstResult($offset)
+ ->getQuery()
+ ->getScalarResult()
+ ;
+
+ return array_map(static fn (array $row): int => (int) $row['accountId'], $rows);
+ }
+
+ public function sumPoints(LoyaltyAccountInterface $account): int
+ {
+ return (int) $this->createQueryBuilder('t')
+ ->select('COALESCE(SUM(t.points), 0)')
+ ->andWhere('t.account = :account')
+ ->setParameter('account', $account)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+ }
+
+ public function findHistoryPage(LoyaltyAccountInterface $account, int $page, int $limit): array
+ {
+ /** @var list $transactions */
+ $transactions = $this->createQueryBuilder('t')
+ ->andWhere('t.account = :account')
+ ->andWhere('t.points != 0')
+ ->setParameter('account', $account)
+ ->orderBy('t.occurredAt', 'DESC')
+ ->addOrderBy('t.id', 'DESC')
+ ->setFirstResult(max(0, $page - 1) * $limit)
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $transactions;
+ }
+
+ public function countHistory(LoyaltyAccountInterface $account): int
+ {
+ return (int) $this->createQueryBuilder('t')
+ ->select('COUNT(t.id)')
+ ->andWhere('t.account = :account')
+ ->andWhere('t.points != 0')
+ ->setParameter('account', $account)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+ }
+
+ public function sumPointsNewerThan(LoyaltyAccountInterface $account, LoyaltyTransactionInterface $transaction): int
+ {
+ return (int) $this->createQueryBuilder('t')
+ ->select('COALESCE(SUM(t.points), 0)')
+ ->andWhere('t.account = :account')
+ ->andWhere('t.occurredAt > :occurredAt OR (t.occurredAt = :occurredAt AND t.id > :id)')
+ ->setParameter('account', $account)
+ ->setParameter('occurredAt', $transaction->getOccurredAt())
+ ->setParameter('id', $transaction->getId())
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+ }
+
+ public function findEarnReferralTransactions(ReferralInterface $referral): array
+ {
+ /** @var list $transactions */
+ $transactions = $this->getEntityManager()
+ ->getRepository(EarnReferralLoyaltyTransaction::class)
+ ->findBy(['referral' => $referral])
+ ;
+
+ return $transactions;
+ }
+
+ public function findClawbackForEarn(CreditLoyaltyTransactionInterface $earn): ?ClawbackLoyaltyTransactionInterface
+ {
+ $clawback = $this->getEntityManager()
+ ->getRepository(ClawbackLoyaltyTransaction::class)
+ ->findOneBy(['earn' => $earn])
+ ;
+ \assert(null === $clawback || $clawback instanceof ClawbackLoyaltyTransactionInterface);
+
+ return $clawback;
+ }
+}
diff --git a/src/Repository/LoyaltyTransactionRepositoryInterface.php b/src/Repository/LoyaltyTransactionRepositoryInterface.php
new file mode 100644
index 0000000..4bf8505
--- /dev/null
+++ b/src/Repository/LoyaltyTransactionRepositoryInterface.php
@@ -0,0 +1,79 @@
+
+ */
+interface LoyaltyTransactionRepositoryInterface extends RepositoryInterface
+{
+ /**
+ * Returns the account's full ledger in replay order (occurredAt ASC, id ASC).
+ *
+ * @return list
+ */
+ public function findForReplay(LoyaltyAccountInterface $account): array;
+
+ public function findEarnOrderTransaction(OrderInterface $order): ?EarnOrderLoyaltyTransactionInterface;
+
+ public function findRedeemTransaction(OrderInterface $order): ?RedeemLoyaltyTransactionInterface;
+
+ public function hasRollback(RedeemLoyaltyTransactionInterface $redeem): bool;
+
+ /**
+ * Returns ids of accounts that have credit lots past their expiry not yet closed by an
+ * expire transaction. Zero-point expire entries close fully consumed lots, so this
+ * selection stays exact.
+ *
+ * @return list
+ */
+ public function findAccountIdsWithExpiredOpenLots(\DateTimeImmutable $now, int $limit, int $offset = 0): array;
+
+ /**
+ * The signed sum of all the account's transactions — must always equal the account's
+ * cached balance (ledger invariant 1).
+ */
+ public function sumPoints(LoyaltyAccountInterface $account): int;
+
+ /**
+ * One page of the customer-facing transaction history: reverse-chronological, excluding
+ * zero-point entries (lot closers).
+ *
+ * @return list
+ */
+ public function findHistoryPage(LoyaltyAccountInterface $account, int $page, int $limit): array;
+
+ public function countHistory(LoyaltyAccountInterface $account): int;
+
+ /**
+ * The signed sum of every transaction newer than the given one (in replay order) — the
+ * bank-statement running balance of the given transaction is the cached balance minus
+ * this sum.
+ */
+ public function sumPointsNewerThan(LoyaltyAccountInterface $account, LoyaltyTransactionInterface $transaction): int;
+
+ /**
+ * Both parties' reward credits for a referral.
+ *
+ * @return list
+ */
+ public function findEarnReferralTransactions(ReferralInterface $referral): array;
+
+ /**
+ * The clawback that already compensates the given credit, if any.
+ */
+ public function findClawbackForEarn(CreditLoyaltyTransactionInterface $earn): ?ClawbackLoyaltyTransactionInterface;
+}
diff --git a/src/Repository/ReferralRepository.php b/src/Repository/ReferralRepository.php
new file mode 100644
index 0000000..24ca428
--- /dev/null
+++ b/src/Repository/ReferralRepository.php
@@ -0,0 +1,93 @@
+findOneBy(['refereeCustomer' => $referee, 'channel' => $channel]);
+ \assert(null === $referral || $referral instanceof ReferralInterface);
+
+ return $referral;
+ }
+
+ public function countRewardedSince(LoyaltyAccountInterface $referrerAccount, \DateTimeImmutable $since): int
+ {
+ return (int) $this->createQueryBuilder('r')
+ ->select('COUNT(r.id)')
+ ->andWhere('r.referrerAccount = :account')
+ ->andWhere('r.status = :status')
+ ->andWhere('r.qualifiedAt >= :since')
+ ->setParameter('account', $referrerAccount)
+ ->setParameter('status', ReferralInterface::STATUS_REWARDED)
+ ->setParameter('since', $since)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+ }
+
+ public function findPendingOlderThan(\DateTimeImmutable $threshold, int $limit): array
+ {
+ /** @var list $referrals */
+ $referrals = $this->createQueryBuilder('r')
+ ->andWhere('r.status = :status')
+ ->andWhere('r.createdAt < :threshold')
+ ->setParameter('status', ReferralInterface::STATUS_PENDING)
+ ->setParameter('threshold', $threshold)
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $referrals;
+ }
+
+ public function findWithIpHashOlderThan(\DateTimeImmutable $threshold, int $limit): array
+ {
+ /** @var list $referrals */
+ $referrals = $this->createQueryBuilder('r')
+ ->andWhere('r.registrationIpHash IS NOT NULL')
+ ->andWhere('r.createdAt < :threshold')
+ ->setParameter('threshold', $threshold)
+ ->setMaxResults($limit)
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $referrals;
+ }
+
+ public function getReferrerStats(LoyaltyAccountInterface $referrerAccount): array
+ {
+ $rewarded = (int) $this->createQueryBuilder('r')
+ ->select('COUNT(r.id)')
+ ->andWhere('r.referrerAccount = :account')
+ ->andWhere('r.status = :status')
+ ->setParameter('account', $referrerAccount)
+ ->setParameter('status', ReferralInterface::STATUS_REWARDED)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ $pointsEarned = (int) $this->getEntityManager()->createQueryBuilder()
+ ->select('COALESCE(SUM(t.points), 0)')
+ ->from(EarnReferralLoyaltyTransaction::class, 't')
+ ->andWhere('t.account = :account')
+ ->setParameter('account', $referrerAccount)
+ ->getQuery()
+ ->getSingleScalarResult()
+ ;
+
+ return ['rewarded' => $rewarded, 'pointsEarned' => $pointsEarned];
+ }
+}
diff --git a/src/Repository/ReferralRepositoryInterface.php b/src/Repository/ReferralRepositoryInterface.php
new file mode 100644
index 0000000..5fa0eef
--- /dev/null
+++ b/src/Repository/ReferralRepositoryInterface.php
@@ -0,0 +1,42 @@
+
+ */
+interface ReferralRepositoryInterface extends RepositoryInterface
+{
+ public function findOneByRefereeAndChannel(CustomerInterface $referee, ChannelInterface $channel): ?ReferralInterface;
+
+ /**
+ * How many referrals the account was rewarded for since the given time — the reward-cap
+ * fraud check.
+ */
+ public function countRewardedSince(LoyaltyAccountInterface $referrerAccount, \DateTimeImmutable $since): int;
+
+ /**
+ * @return list
+ */
+ public function findPendingOlderThan(\DateTimeImmutable $threshold, int $limit): array;
+
+ /**
+ * @return list
+ */
+ public function findWithIpHashOlderThan(\DateTimeImmutable $threshold, int $limit): array;
+
+ /**
+ * Aggregate stats for the shop referral block.
+ *
+ * @return array{rewarded: int, pointsEarned: int}
+ */
+ public function getReferrerStats(LoyaltyAccountInterface $referrerAccount): array;
+}
diff --git a/src/Repository/TierRepository.php b/src/Repository/TierRepository.php
new file mode 100644
index 0000000..89dae07
--- /dev/null
+++ b/src/Repository/TierRepository.php
@@ -0,0 +1,28 @@
+ $tiers */
+ $tiers = $this->createQueryBuilder('t')
+ ->andWhere('t.channel = :channel')
+ ->andWhere('t.enabled = true')
+ ->setParameter('channel', $channel)
+ ->orderBy('t.position', 'DESC')
+ ->addOrderBy('t.id', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $tiers;
+ }
+}
diff --git a/src/Repository/TierRepositoryInterface.php b/src/Repository/TierRepositoryInterface.php
new file mode 100644
index 0000000..3229c8f
--- /dev/null
+++ b/src/Repository/TierRepositoryInterface.php
@@ -0,0 +1,22 @@
+
+ */
+interface TierRepositoryInterface extends RepositoryInterface
+{
+ /**
+ * The channel's enabled tiers, highest position first — evaluation order.
+ *
+ * @return list
+ */
+ public function findQualifiable(ChannelInterface $channel): array;
+}
diff --git a/src/Resolver/TriggerChannelResolver.php b/src/Resolver/TriggerChannelResolver.php
new file mode 100644
index 0000000..637ef77
--- /dev/null
+++ b/src/Resolver/TriggerChannelResolver.php
@@ -0,0 +1,79 @@
+ $channelRepository
+ * @param class-string $orderClass
+ */
+ public function __construct(
+ private readonly ChannelContextInterface $channelContext,
+ private readonly ChannelRepositoryInterface $channelRepository,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly string $orderClass,
+ ) {
+ }
+
+ public function resolve(EarningTriggerEvent $event): ?ChannelInterface
+ {
+ $channel = $event->getChannel();
+ if (null !== $channel) {
+ return $channel;
+ }
+
+ try {
+ return $this->channelContext->getChannel();
+ } catch (ChannelNotFoundException) {
+ // The context could not resolve a channel for this request (e.g. CLI); fall
+ // through to the order-history and single-channel strategies
+ }
+
+ $channel = $this->latestOrderChannel($event);
+ if (null !== $channel) {
+ return $channel;
+ }
+
+ $enabledChannels = $this->channelRepository->findBy(['enabled' => true]);
+ if (1 === count($enabledChannels)) {
+ return $enabledChannels[0];
+ }
+
+ return null;
+ }
+
+ private function latestOrderChannel(EarningTriggerEvent $event): ?ChannelInterface
+ {
+ $order = $this->entityManager->createQueryBuilder()
+ ->select('o')
+ ->from($this->orderClass, 'o')
+ ->andWhere('o.customer = :customer')
+ ->andWhere('o.checkoutState = :checkoutState')
+ ->setParameter('customer', $event->getCustomer())
+ ->setParameter('checkoutState', OrderCheckoutStates::STATE_COMPLETED)
+ ->orderBy('o.checkoutCompletedAt', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+
+ return $order instanceof OrderInterface ? $order->getChannel() : null;
+ }
+}
diff --git a/src/Resolver/TriggerChannelResolverInterface.php b/src/Resolver/TriggerChannelResolverInterface.php
new file mode 100644
index 0000000..7d4e5a8
--- /dev/null
+++ b/src/Resolver/TriggerChannelResolverInterface.php
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/CreditLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/CreditLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..e57f0d1
--- /dev/null
+++ b/src/Resources/config/doctrine/model/CreditLoyaltyTransaction.orm.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/DebitLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/DebitLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..25115e4
--- /dev/null
+++ b/src/Resources/config/doctrine/model/DebitLoyaltyTransaction.orm.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/DryRunResult.orm.xml b/src/Resources/config/doctrine/model/DryRunResult.orm.xml
new file mode 100644
index 0000000..8dc0391
--- /dev/null
+++ b/src/Resources/config/doctrine/model/DryRunResult.orm.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/EarnActionLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/EarnActionLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..95a7359
--- /dev/null
+++ b/src/Resources/config/doctrine/model/EarnActionLoyaltyTransaction.orm.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/EarnOrderLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/EarnOrderLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..bc91aa3
--- /dev/null
+++ b/src/Resources/config/doctrine/model/EarnOrderLoyaltyTransaction.orm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/EarnReferralLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/EarnReferralLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..de1c90c
--- /dev/null
+++ b/src/Resources/config/doctrine/model/EarnReferralLoyaltyTransaction.orm.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/EarningRule.orm.xml b/src/Resources/config/doctrine/model/EarningRule.orm.xml
new file mode 100644
index 0000000..989deab
--- /dev/null
+++ b/src/Resources/config/doctrine/model/EarningRule.orm.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/EarningRuleCondition.orm.xml b/src/Resources/config/doctrine/model/EarningRuleCondition.orm.xml
new file mode 100644
index 0000000..8e43169
--- /dev/null
+++ b/src/Resources/config/doctrine/model/EarningRuleCondition.orm.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/ExpireLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/ExpireLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..66310cb
--- /dev/null
+++ b/src/Resources/config/doctrine/model/ExpireLoyaltyTransaction.orm.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/LoyaltyAccount.orm.xml b/src/Resources/config/doctrine/model/LoyaltyAccount.orm.xml
new file mode 100644
index 0000000..2abc953
--- /dev/null
+++ b/src/Resources/config/doctrine/model/LoyaltyAccount.orm.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/LoyaltyProgram.orm.xml b/src/Resources/config/doctrine/model/LoyaltyProgram.orm.xml
new file mode 100644
index 0000000..26a6942
--- /dev/null
+++ b/src/Resources/config/doctrine/model/LoyaltyProgram.orm.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/LoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/LoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..c40a235
--- /dev/null
+++ b/src/Resources/config/doctrine/model/LoyaltyTransaction.orm.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/ManualCreditLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/ManualCreditLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..e9aab96
--- /dev/null
+++ b/src/Resources/config/doctrine/model/ManualCreditLoyaltyTransaction.orm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/ManualDebitLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/ManualDebitLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..efe52d9
--- /dev/null
+++ b/src/Resources/config/doctrine/model/ManualDebitLoyaltyTransaction.orm.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/RedeemLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/RedeemLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..78082ca
--- /dev/null
+++ b/src/Resources/config/doctrine/model/RedeemLoyaltyTransaction.orm.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/RedeemRollbackLoyaltyTransaction.orm.xml b/src/Resources/config/doctrine/model/RedeemRollbackLoyaltyTransaction.orm.xml
new file mode 100644
index 0000000..a0b8ba9
--- /dev/null
+++ b/src/Resources/config/doctrine/model/RedeemRollbackLoyaltyTransaction.orm.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/Referral.orm.xml b/src/Resources/config/doctrine/model/Referral.orm.xml
new file mode 100644
index 0000000..2b6935d
--- /dev/null
+++ b/src/Resources/config/doctrine/model/Referral.orm.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/Tier.orm.xml b/src/Resources/config/doctrine/model/Tier.orm.xml
new file mode 100644
index 0000000..cceb62c
--- /dev/null
+++ b/src/Resources/config/doctrine/model/Tier.orm.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/doctrine/model/TierTranslation.orm.xml b/src/Resources/config/doctrine/model/TierTranslation.orm.xml
new file mode 100644
index 0000000..ceaaaff
--- /dev/null
+++ b/src/Resources/config/doctrine/model/TierTranslation.orm.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/routes/admin.yaml b/src/Resources/config/routes/admin.yaml
index be4ae0c..73dbb2a 100644
--- a/src/Resources/config/routes/admin.yaml
+++ b/src/Resources/config/routes/admin.yaml
@@ -1 +1,115 @@
-# TODO Add your admin routes here
\ No newline at end of file
+setono_sylius_loyalty_admin_dashboard:
+ path: /loyalty
+ methods: [GET]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\DashboardAction
+
+setono_sylius_loyalty_admin_account_index:
+ path: /loyalty/accounts
+ methods: [GET]
+ defaults:
+ _controller: setono_sylius_loyalty.controller.account::indexAction
+ _sylius:
+ section: admin
+ grid: setono_sylius_loyalty_admin_account
+ permission: true
+ template: "@SyliusAdmin/Crud/index.html.twig"
+
+setono_sylius_loyalty_admin_account_inspect:
+ path: /loyalty/accounts/{id}
+ methods: [GET]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\InspectAccountAction
+
+setono_sylius_loyalty_admin_account_toggle:
+ path: /loyalty/accounts/{id}/toggle
+ methods: [POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\ToggleAccountAction
+
+setono_sylius_loyalty_admin_account_adjust:
+ path: /loyalty/accounts/{id}/adjust
+ methods: [POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\ManualAdjustmentAction
+
+setono_sylius_loyalty_admin_earning_rule:
+ resource: |
+ alias: setono_sylius_loyalty.earning_rule
+ section: admin
+ except: ['show']
+ redirect: index
+ grid: setono_sylius_loyalty_admin_earning_rule
+ templates: "@SyliusAdmin\\Crud"
+ permission: true
+ path: loyalty/earning-rules
+ type: sylius.resource
+
+setono_sylius_loyalty_admin_tier:
+ resource: |
+ alias: setono_sylius_loyalty.tier
+ section: admin
+ except: ['show']
+ redirect: index
+ grid: setono_sylius_loyalty_admin_tier
+ templates: "@SyliusAdmin\\Crud"
+ permission: true
+ path: loyalty/tiers
+ type: sylius.resource
+
+setono_sylius_loyalty_admin_program_index:
+ path: /loyalty/programs
+ methods: [GET]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\ProgramIndexAction
+
+setono_sylius_loyalty_admin_program_update:
+ path: /loyalty/programs/{id}/edit
+ methods: [GET, PUT]
+ defaults:
+ _controller: setono_sylius_loyalty.controller.program::updateAction
+ _sylius:
+ section: admin
+ permission: true
+ template: "@SyliusAdmin/Crud/update.html.twig"
+ redirect: setono_sylius_loyalty_admin_program_index
+
+setono_sylius_loyalty_admin_dry_run_result_index:
+ path: /loyalty/dry-run-results
+ methods: [GET]
+ defaults:
+ _controller: setono_sylius_loyalty.controller.dry_run_result::indexAction
+ _sylius:
+ section: admin
+ grid: setono_sylius_loyalty_admin_dry_run_result
+ permission: true
+ template: "@SyliusAdmin/Crud/index.html.twig"
+
+setono_sylius_loyalty_admin_rule_tester:
+ path: /loyalty/rule-tester
+ methods: [GET, POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\RuleTesterAction
+
+setono_sylius_loyalty_admin_expression_lint:
+ path: /loyalty/expression-lint
+ methods: [POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\LintExpressionAction
+
+setono_sylius_loyalty_admin_referral_index:
+ path: /loyalty/referrals
+ methods: [GET]
+ defaults:
+ _controller: setono_sylius_loyalty.controller.referral::indexAction
+ _sylius:
+ section: admin
+ permission: true
+ grid: setono_sylius_loyalty_admin_referral
+ template: "@SyliusAdmin/Crud/index.html.twig"
+
+setono_sylius_loyalty_admin_referral_override:
+ path: /loyalty/referrals/{id}/override
+ methods: [POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Admin\OverrideReferralAction
diff --git a/src/Resources/config/routes/shop.yaml b/src/Resources/config/routes/shop.yaml
index 2333897..7761c43 100644
--- a/src/Resources/config/routes/shop.yaml
+++ b/src/Resources/config/routes/shop.yaml
@@ -1 +1,23 @@
-# TODO Add your shop routes here
\ No newline at end of file
+setono_sylius_loyalty_shop_referral_landing:
+ path: /r/{code}
+ methods: [GET]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\ReferralLandingAction
+
+setono_sylius_loyalty_shop_account_loyalty:
+ path: /account/loyalty
+ methods: [GET]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\LoyaltyDashboardAction
+
+setono_sylius_loyalty_shop_redemption_apply:
+ path: /cart/loyalty-redemption
+ methods: [POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\ApplyRedemptionAction
+
+setono_sylius_loyalty_shop_redemption_remove:
+ path: /cart/loyalty-redemption/remove
+ methods: [POST]
+ defaults:
+ _controller: Setono\SyliusLoyaltyPlugin\Controller\Action\Shop\RemoveRedemptionAction
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
index 2a861e3..e0901a1 100644
--- a/src/Resources/config/services.xml
+++ b/src/Resources/config/services.xml
@@ -1,7 +1,26 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/admin.xml b/src/Resources/config/services/admin.xml
new file mode 100644
index 0000000..1fc3dca
--- /dev/null
+++ b/src/Resources/config/services/admin.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+ %setono_sylius_loyalty.model.transaction.class%
+ %setono_sylius_loyalty.model.program.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+ %setono_sylius_loyalty.manual_adjustment_reasons%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+ %setono_sylius_loyalty.manual_adjustment_reasons%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/command.xml b/src/Resources/config/services/command.xml
new file mode 100644
index 0000000..d76d90f
--- /dev/null
+++ b/src/Resources/config/services/command.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+ %sylius.model.customer.class%
+
+
+
+
+
+ %setono_sylius_loyalty.model.dry_run_result.class%
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
diff --git a/src/Resources/config/services/doctrine.xml b/src/Resources/config/services/doctrine.xml
new file mode 100644
index 0000000..02f2815
--- /dev/null
+++ b/src/Resources/config/services/doctrine.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ %setono_sylius_loyalty.model.transaction.class%
+ %sylius.resources%
+
+
+
+
diff --git a/src/Resources/config/services/earning_rule.xml b/src/Resources/config/services/earning_rule.xml
new file mode 100644
index 0000000..ad90068
--- /dev/null
+++ b/src/Resources/config/services/earning_rule.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sylius.model.order.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/expression.xml b/src/Resources/config/services/expression.xml
new file mode 100644
index 0000000..3e395b5
--- /dev/null
+++ b/src/Resources/config/services/expression.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sylius.model.order.class%
+
+
+
+
+
+
+
+
+
+
+
+
+ floor
+
+
+
+ ceil
+
+
+
+ round
+
+
+
+ abs
+
+
+
+ min
+
+
+
+ max
+
+
+
+
diff --git a/src/Resources/config/services/fixture.xml b/src/Resources/config/services/fixture.xml
new file mode 100644
index 0000000..45644ee
--- /dev/null
+++ b/src/Resources/config/services/fixture.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/form.xml b/src/Resources/config/services/form.xml
new file mode 100644
index 0000000..d2b612c
--- /dev/null
+++ b/src/Resources/config/services/form.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.earning_rule_condition.class%
+
+
+
+
+
+
+ %setono_sylius_loyalty.trigger_catalog%
+ %setono_sylius_loyalty.model.earning_rule.class%
+
+
+
+
+ %setono_sylius_loyalty.model.program.class%
+
+
+
+
+
+ %setono_sylius_loyalty.model.tier.class%
+
+
+
+
+ %setono_sylius_loyalty.model.tier_translation.class%
+
+
+
+
+
+
+ %setono_sylius_loyalty.expression_editor.cdn_base_url%
+
+
+
+
diff --git a/src/Resources/config/services/hint.xml b/src/Resources/config/services/hint.xml
new file mode 100644
index 0000000..f9838c2
--- /dev/null
+++ b/src/Resources/config/services/hint.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/inspector.xml b/src/Resources/config/services/inspector.xml
new file mode 100644
index 0000000..8bdadac
--- /dev/null
+++ b/src/Resources/config/services/inspector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/ledger.xml b/src/Resources/config/services/ledger.xml
new file mode 100644
index 0000000..909614d
--- /dev/null
+++ b/src/Resources/config/services/ledger.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
diff --git a/src/Resources/config/services/listener.xml b/src/Resources/config/services/listener.xml
new file mode 100644
index 0000000..6aaa5bf
--- /dev/null
+++ b/src/Resources/config/services/listener.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.retain_anonymized_ledger%
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
diff --git a/src/Resources/config/services/order_earning.xml b/src/Resources/config/services/order_earning.xml
new file mode 100644
index 0000000..98bf9f3
--- /dev/null
+++ b/src/Resources/config/services/order_earning.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sylius.model.order.class%
+
+
+
+
+
+
+
+ %sylius.model.customer.class%
+ %sylius.model.channel.class%
+ %sylius.model.order.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/promotion.xml b/src/Resources/config/services/promotion.xml
new file mode 100644
index 0000000..ff5f879
--- /dev/null
+++ b/src/Resources/config/services/promotion.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/provider.xml b/src/Resources/config/services/provider.xml
new file mode 100644
index 0000000..04fd457
--- /dev/null
+++ b/src/Resources/config/services/provider.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/redemption.xml b/src/Resources/config/services/redemption.xml
new file mode 100644
index 0000000..79f525d
--- /dev/null
+++ b/src/Resources/config/services/redemption.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/referral.xml b/src/Resources/config/services/referral.xml
new file mode 100644
index 0000000..6617e92
--- /dev/null
+++ b/src/Resources/config/services/referral.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.referral.registration_ip_check%
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.referral.reward_cap%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.referral.query_parameter%
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.referral.registration_ip_check%
+ %setono_sylius_loyalty.referral.ip_hash_salt%
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/repository.xml b/src/Resources/config/services/repository.xml
new file mode 100644
index 0000000..9fa24bd
--- /dev/null
+++ b/src/Resources/config/services/repository.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/shop.xml b/src/Resources/config/services/shop.xml
new file mode 100644
index 0000000..0ff7920
--- /dev/null
+++ b/src/Resources/config/services/shop.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %setono_sylius_loyalty.model.account.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/tier.xml b/src/Resources/config/services/tier.xml
new file mode 100644
index 0000000..1364ac1
--- /dev/null
+++ b/src/Resources/config/services/tier.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+ %setono_sylius_loyalty.model.transaction.class%
+
+
+
+
+
+ %sylius.model.order.class%
+
+
+
+
+
+ %sylius.model.order.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/services/trigger.xml b/src/Resources/config/services/trigger.xml
new file mode 100644
index 0000000..379e1cf
--- /dev/null
+++ b/src/Resources/config/services/trigger.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %sylius.model.order.class%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Resources/config/validation/Order.xml b/src/Resources/config/validation/Order.xml
new file mode 100644
index 0000000..575eac4
--- /dev/null
+++ b/src/Resources/config/validation/Order.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/src/Resources/public/admin/expression-editor/completion.js b/src/Resources/public/admin/expression-editor/completion.js
new file mode 100644
index 0000000..ddc64b0
--- /dev/null
+++ b/src/Resources/public/admin/expression-editor/completion.js
@@ -0,0 +1,62 @@
+/*
+ * Catalog-driven autocompletion: the data-catalog attribute carries the same typed
+ * class-to-members map that drives the server-side sandbox, so completion can never suggest
+ * a path the validator rejects. Dotted chains are resolved by walking the type graph.
+ */
+
+export function createCompletionSource(catalog, trigger) {
+ const variables = availableVariables(catalog, trigger);
+
+ return (context) => {
+ const chain = context.matchBefore(/[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]*)*/);
+ if (chain === null && !context.explicit) {
+ return null;
+ }
+
+ const text = chain ? chain.text : '';
+ const parts = text.split('.');
+
+ if (parts.length === 1) {
+ const options = [];
+ for (const [name, variable] of Object.entries(variables)) {
+ options.push({label: name, type: 'variable', detail: variable.type});
+ }
+ for (const fn of catalog.functions || []) {
+ options.push({label: fn.name, type: 'function', detail: fn.signature, info: fn.description, apply: fn.name + '()'});
+ }
+
+ return {from: chain ? chain.from : context.pos, options, validFor: /^[a-zA-Z0-9_]*$/};
+ }
+
+ // Resolve the chain up to the last complete segment through the type graph
+ let type = (variables[parts[0]] || {}).type;
+ for (let i = 1; i < parts.length - 1 && type; i++) {
+ const members = catalog.types[type] || {};
+ type = members[parts[i]];
+ }
+
+ const members = type ? catalog.types[type] || {} : {};
+ const from = chain.from + text.lastIndexOf('.') + 1;
+
+ return {
+ from,
+ options: Object.entries(members).map(([name, memberType]) => ({
+ label: name,
+ type: 'property',
+ detail: memberType,
+ })),
+ validFor: /^[a-zA-Z0-9_]*$/,
+ };
+ };
+}
+
+export function availableVariables(catalog, trigger) {
+ const variables = {};
+ for (const [name, variable] of Object.entries(catalog.variables || {})) {
+ if (variable.triggers === null || !trigger || variable.triggers.includes(trigger)) {
+ variables[name] = variable;
+ }
+ }
+
+ return variables;
+}
diff --git a/src/Resources/public/admin/expression-editor/editor.js b/src/Resources/public/admin/expression-editor/editor.js
new file mode 100644
index 0000000..57ea54c
--- /dev/null
+++ b/src/Resources/public/admin/expression-editor/editor.js
@@ -0,0 +1,92 @@
+/*
+ * The expression editor bootstrap. CodeMirror 6 has no official single-file browser build, so
+ * it is loaded as version-pinned ESM imports from a CDN (the base URL comes from the bundle
+ * config and can point at self-hosted files for intranet or strict-CSP setups). This glue is
+ * hand-written, buildless ES modules — no Node toolchain.
+ */
+
+import {createElLanguage} from './el-language.js';
+import {createCompletionSource} from './completion.js';
+import {createLintSource} from './lint.js';
+import {renderReferencePanel} from './reference-panel.js';
+
+const CODEMIRROR_VERSION = '6.0.1';
+
+async function loadCodeMirror(cdnBaseUrl) {
+ const base = cdnBaseUrl.replace(/\/$/, '');
+
+ // A relative base means self-hosted files (strict-CSP/intranet setups, hermetic CI):
+ // plain .js shims instead of the CDN's versioned bare-specifier URLs
+ if (!/^https?:/.test(base)) {
+ const [codemirror, language, autocomplete, lint] = await Promise.all([
+ import(`${base}/codemirror.js`),
+ import(`${base}/codemirror-language.js`),
+ import(`${base}/codemirror-autocomplete.js`),
+ import(`${base}/codemirror-lint.js`),
+ ]);
+
+ return {codemirror, language, autocomplete, lint};
+ }
+
+ const [codemirror, language, autocomplete, lint] = await Promise.all([
+ import(`${base}/codemirror@${CODEMIRROR_VERSION}`),
+ import(`${base}/@codemirror/language@6`),
+ import(`${base}/@codemirror/autocomplete@6`),
+ import(`${base}/@codemirror/lint@6`),
+ ]);
+
+ return {codemirror, language, autocomplete, lint};
+}
+
+export async function enhance(textarea) {
+ if (textarea.dataset.setonoSyliusLoyaltyEnhanced === '1') {
+ return;
+ }
+ textarea.dataset.setonoSyliusLoyaltyEnhanced = '1';
+
+ const catalog = JSON.parse(textarea.dataset.catalog || '{}');
+ const trigger = textarea.dataset.trigger || null;
+ const {codemirror, language, autocomplete, lint} = await loadCodeMirror(textarea.dataset.cdnBaseUrl || 'https://esm.sh');
+
+ const view = new codemirror.EditorView({
+ doc: textarea.value,
+ extensions: [
+ // The persistent way to keep a class on view.dom — CodeMirror recomputes the
+ // element's className on every update, wiping externally added classes
+ codemirror.EditorView.editorAttributes.of({class: 'setono-sylius-loyalty-expression-editor'}),
+ codemirror.basicSetup,
+ createElLanguage(language.StreamLanguage),
+ autocomplete.autocompletion({override: [createCompletionSource(catalog, trigger)]}),
+ lint.linter(createLintSource(textarea.dataset.lintUrl, trigger), {delay: 500}),
+ lint.lintGutter(),
+ codemirror.EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ textarea.value = update.state.doc.toString();
+ }
+ }),
+ ],
+ });
+
+ textarea.style.display = 'none';
+ textarea.after(view.dom);
+ view.dom.style.cssText = 'border:1px solid rgba(34,36,38,.15);border-radius:.28em;background:#fff;min-height:3.5em;';
+
+ renderReferencePanel(
+ view.dom.parentElement,
+ catalog,
+ trigger,
+ (expression) => {
+ view.dispatch({changes: {from: 0, to: view.state.doc.length, insert: expression}});
+ textarea.value = expression;
+ },
+ JSON.parse(textarea.dataset.referenceTranslations || '{}'),
+ );
+}
+
+export function enhanceAll(root = document) {
+ for (const textarea of root.querySelectorAll('textarea[data-setono-sylius-loyalty-expression]')) {
+ enhance(textarea);
+ }
+}
+
+enhanceAll();
diff --git a/src/Resources/public/admin/expression-editor/el-language.js b/src/Resources/public/admin/expression-editor/el-language.js
new file mode 100644
index 0000000..7bd0de7
--- /dev/null
+++ b/src/Resources/public/admin/expression-editor/el-language.js
@@ -0,0 +1,54 @@
+/*
+ * A pragmatic StreamLanguage tokenizer for Symfony ExpressionLanguage — strings, numbers,
+ * operators (incl. and/or/not/in/matches), identifiers, function calls, and properties.
+ * Deliberately not a full Lezer grammar.
+ */
+
+const KEYWORD_OPERATORS = ['and', 'or', 'not', 'in', 'matches', 'starts', 'ends', 'with', 'contains'];
+const CONSTANTS = ['true', 'false', 'null'];
+
+export function createElLanguage(streamLanguage) {
+ return streamLanguage.define({
+ name: 'expression-language',
+ token(stream) {
+ if (stream.eatSpace()) {
+ return null;
+ }
+
+ if (stream.match(/"([^"\\]|\\.)*"/) || stream.match(/'([^'\\]|\\.)*'/)) {
+ return 'string';
+ }
+
+ if (stream.match(/\d+(\.\d+)?/)) {
+ return 'number';
+ }
+
+ if (stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/)) {
+ const word = stream.current();
+ if (KEYWORD_OPERATORS.includes(word)) {
+ return 'operatorKeyword';
+ }
+ if (CONSTANTS.includes(word)) {
+ return 'atom';
+ }
+ if (stream.peek() === '(') {
+ return 'function';
+ }
+
+ return 'variableName';
+ }
+
+ if (stream.match(/\.[a-zA-Z_][a-zA-Z0-9_]*/)) {
+ return 'propertyName';
+ }
+
+ if (stream.match(/(===|!==|==|!=|<=|>=|&&|\|\||\*\*|\.\.|[+\-*/%<>!?:~])/)) {
+ return 'operator';
+ }
+
+ stream.next();
+
+ return null;
+ },
+ });
+}
diff --git a/src/Resources/public/admin/expression-editor/lint.js b/src/Resources/public/admin/expression-editor/lint.js
new file mode 100644
index 0000000..a9596f3
--- /dev/null
+++ b/src/Resources/public/admin/expression-editor/lint.js
@@ -0,0 +1,32 @@
+/*
+ * Debounced inline linting through the admin-only XHR route, which runs the same server-side
+ * parse + whitelist used on save.
+ */
+
+export function createLintSource(lintUrl, trigger) {
+ return async (view) => {
+ const expression = view.state.doc.toString();
+ if (expression.trim() === '') {
+ return [];
+ }
+
+ const response = await fetch(lintUrl, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({expression, trigger: trigger || null}),
+ });
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const payload = await response.json();
+
+ return (payload.diagnostics || []).map((diagnostic) => ({
+ from: 0,
+ to: view.state.doc.length,
+ severity: 'error',
+ message: diagnostic.message,
+ }));
+ };
+}
diff --git a/src/Resources/public/admin/expression-editor/reference-panel.js b/src/Resources/public/admin/expression-editor/reference-panel.js
new file mode 100644
index 0000000..f8164cb
--- /dev/null
+++ b/src/Resources/public/admin/expression-editor/reference-panel.js
@@ -0,0 +1,84 @@
+/*
+ * The collapsible expression reference panel, generated from the same catalog that feeds
+ * autocompletion — custom variables and functions registered by plugin users appear
+ * automatically. Clicking an example inserts it into the editor.
+ */
+
+import {availableVariables} from './completion.js';
+
+export function renderReferencePanel(container, catalog, trigger, insert, translations) {
+ const t = (key, fallback) => (translations && translations[key]) || fallback;
+
+ const details = document.createElement('details');
+ details.className = 'setono-sylius-loyalty-expression-reference';
+ details.style.marginTop = '.5em';
+
+ const summary = document.createElement('summary');
+ summary.textContent = t('reference', 'Expression reference');
+ summary.style.cursor = 'pointer';
+ details.appendChild(summary);
+
+ const body = document.createElement('div');
+ body.style.cssText = 'font-size:.92em;padding:.5em .75em;border:1px solid rgba(34,36,38,.15);border-radius:.28em;margin-top:.25em;';
+
+ body.appendChild(section(t('variables', 'Variables')));
+ const variableList = document.createElement('ul');
+ for (const [name, variable] of Object.entries(availableVariables(catalog, trigger))) {
+ const item = document.createElement('li');
+ const members = catalog.types[variable.type];
+ const memberHint = members ? ' — ' + Object.keys(members).map((m) => name + '.' + m).slice(0, 4).join(', ') : '';
+ item.innerHTML = `${name} (${variable.type})${memberHint}`;
+ variableList.appendChild(item);
+ }
+ body.appendChild(variableList);
+
+ body.appendChild(section(t('functions', 'Functions')));
+ const functionList = document.createElement('ul');
+ for (const fn of catalog.functions || []) {
+ const item = document.createElement('li');
+ item.innerHTML = `${fn.signature}`;
+ functionList.appendChild(item);
+ }
+ body.appendChild(functionList);
+
+ body.appendChild(section(t('syntax', 'Syntax')));
+ const syntax = document.createElement('p');
+ syntax.innerHTML = 'and or not · == != < >= · a ? b : c · x in [1, 2] · name matches "/^A/" · "a" ~ "b"';
+ body.appendChild(syntax);
+
+ body.appendChild(section(t('examples', 'Examples — click to insert')));
+ const examples = [
+ {label: t('example_double_points', 'Double points above 500'), expression: 'basis > 50000 ? floor(basis / 50) : floor(basis / 100)'},
+ {label: t('example_weekend_bonus', 'Weekend bonus'), expression: 'day_of_week() in [6, 7]'},
+ {label: t('example_first_order', 'First-order bonus'), expression: 'is_first_order() ? 500 : 0'},
+ ];
+ const exampleList = document.createElement('ul');
+ for (const example of examples) {
+ const item = document.createElement('li');
+ const link = document.createElement('a');
+ link.href = '#';
+ link.textContent = example.label;
+ link.addEventListener('click', (event) => {
+ event.preventDefault();
+ insert(example.expression);
+ });
+ item.appendChild(link);
+ item.appendChild(document.createTextNode(' — '));
+ const code = document.createElement('code');
+ code.textContent = example.expression;
+ item.appendChild(code);
+ exampleList.appendChild(item);
+ }
+ body.appendChild(exampleList);
+
+ details.appendChild(body);
+ container.appendChild(details);
+}
+
+function section(title) {
+ const heading = document.createElement('h5');
+ heading.textContent = title;
+ heading.style.margin = '.5em 0 .25em';
+
+ return heading;
+}
diff --git a/src/Resources/public/shop/earn-hint.js b/src/Resources/public/shop/earn-hint.js
new file mode 100644
index 0000000..df1377b
--- /dev/null
+++ b/src/Resources/public/shop/earn-hint.js
@@ -0,0 +1,50 @@
+/*
+ * Variant-aware earn hint: the number swaps on variant change without XHR, using the
+ * server-rendered per-variant map. Mirrors the selector mechanics of Sylius'
+ * sylius-variants-prices.js for option-based selection and listens to the variant radios
+ * for choice-based selection.
+ */
+(function () {
+ 'use strict';
+
+ var hint = document.getElementById('setono-sylius-loyalty-earn-hint');
+ if (!hint) {
+ return;
+ }
+
+ var map = document.getElementById('setono-sylius-loyalty-earn-hint-map');
+ var text = document.getElementById('setono-sylius-loyalty-earn-hint-text');
+ var template = hint.getAttribute('data-template');
+
+ function render(entry) {
+ var points = entry ? entry.getAttribute('data-points') : '';
+ if (points === '' || points === null) {
+ hint.style.display = 'none';
+
+ return;
+ }
+ hint.style.display = '';
+ text.textContent = template.replace('%points%', points);
+ }
+
+ function onOptionsChange() {
+ var selector = '';
+ document.querySelectorAll('#sylius-product-adding-to-cart select[data-option]').forEach(function (select) {
+ selector += '[data-' + select.getAttribute('data-option') + '="' + select.value + '"]';
+ });
+ if (selector !== '') {
+ render(map.querySelector('span' + selector));
+ }
+ }
+
+ function onVariantChoice(event) {
+ render(map.querySelector('span[data-variant-code="' + event.target.value + '"]'));
+ }
+
+ document.querySelectorAll('#sylius-product-adding-to-cart select[data-option]').forEach(function (select) {
+ select.addEventListener('change', onOptionsChange);
+ });
+ document.querySelectorAll('[name*="[variant]"][type="radio"]').forEach(function (radio) {
+ radio.addEventListener('change', onVariantChoice);
+ });
+})();
diff --git a/src/Resources/translations/flashes.cs.yaml b/src/Resources/translations/flashes.cs.yaml
new file mode 100644
index 0000000..a65fb8f
--- /dev/null
+++ b/src/Resources/translations/flashes.cs.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Věrnostní účet byl deaktivován.
+ account_enabled: Věrnostní účet byl aktivován.
+ adjustment_applied: Ruční úprava byla zapsána do knihy transakcí.
+ adjustment_invalid: Úprava je neplatná — body musí být nenulové, poznámka je povinná a důvod musí být jedním z nakonfigurovaných kódů.
+ redemption_applied: Vaše body byly uplatněny na objednávku.
+ redemption_invalid_points: Tento počet bodů nelze na tuto objednávku uplatnit.
+ redemption_invalid_request: Požadavek se nepodařilo zpracovat. Zkuste to prosím znovu.
+ redemption_not_available: Uplatnění bodů není momentálně k dispozici.
+ redemption_removed: Body byly z objednávky odebrány.
+ referral_overridden: Doporučení bylo schváleno a odměněno.
+ referral_override_invalid: Požadavek se nepodařilo zpracovat. Zkuste to prosím znovu.
+ referral_override_not_rejected: Ručně schválit lze pouze zamítnutá doporučení.
diff --git a/src/Resources/translations/flashes.da.yaml b/src/Resources/translations/flashes.da.yaml
new file mode 100644
index 0000000..87065bf
--- /dev/null
+++ b/src/Resources/translations/flashes.da.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Loyalitetskontoen er blevet deaktiveret.
+ account_enabled: Loyalitetskontoen er blevet aktiveret.
+ adjustment_applied: Den manuelle justering er skrevet til hovedbogen.
+ adjustment_invalid: Justeringen er ugyldig — point skal være forskellige fra nul, notatet er obligatorisk, og årsagen skal være en af de konfigurerede koder.
+ redemption_applied: Dine point er blevet anvendt på ordren.
+ redemption_invalid_points: Det antal point kan ikke anvendes på denne ordre.
+ redemption_invalid_request: Anmodningen kunne ikke behandles. Prøv venligst igen.
+ redemption_not_available: Indløsning af point er ikke tilgængelig lige nu.
+ redemption_removed: Pointene er blevet fjernet fra ordren.
+ referral_overridden: Henvisningen er blevet godkendt og belønnet.
+ referral_override_invalid: Anmodningen kunne ikke behandles. Prøv venligst igen.
+ referral_override_not_rejected: Kun afviste henvisninger kan tilsidesættes.
diff --git a/src/Resources/translations/flashes.de.yaml b/src/Resources/translations/flashes.de.yaml
new file mode 100644
index 0000000..fa9117b
--- /dev/null
+++ b/src/Resources/translations/flashes.de.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Das Treuekonto wurde deaktiviert.
+ account_enabled: Das Treuekonto wurde aktiviert.
+ adjustment_applied: Die manuelle Anpassung wurde in den Ledger geschrieben.
+ adjustment_invalid: Die Anpassung ist ungültig — die Punkte müssen ungleich null sein, die Notiz ist Pflicht, und der Grund muss einer der konfigurierten Codes sein.
+ redemption_applied: Ihre Punkte wurden auf die Bestellung angewendet.
+ redemption_invalid_points: Diese Punktzahl kann auf diese Bestellung nicht angewendet werden.
+ redemption_invalid_request: Die Anfrage konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut.
+ redemption_not_available: Das Einlösen von Punkten ist derzeit nicht möglich.
+ redemption_removed: Die Punkte wurden von der Bestellung entfernt.
+ referral_overridden: Die Empfehlung wurde genehmigt und belohnt.
+ referral_override_invalid: Die Anfrage konnte nicht verarbeitet werden. Bitte versuchen Sie es erneut.
+ referral_override_not_rejected: Nur abgelehnte Empfehlungen können übersteuert werden.
diff --git a/src/Resources/translations/flashes.en.yaml b/src/Resources/translations/flashes.en.yaml
new file mode 100644
index 0000000..a0a4e03
--- /dev/null
+++ b/src/Resources/translations/flashes.en.yaml
@@ -0,0 +1,13 @@
+setono_sylius_loyalty:
+ account_disabled: The loyalty account has been disabled.
+ account_enabled: The loyalty account has been enabled.
+ adjustment_applied: The manual adjustment has been written to the ledger.
+ adjustment_invalid: The adjustment is invalid — points must be non-zero, the note is mandatory, and the reason must be one of the configured codes.
+ redemption_applied: Your points have been applied to the order.
+ redemption_invalid_points: That number of points cannot be applied to this order.
+ redemption_invalid_request: The request could not be processed. Please try again.
+ redemption_not_available: Redeeming points is not available right now.
+ redemption_removed: The points have been removed from the order.
+ referral_overridden: The referral has been approved and rewarded.
+ referral_override_invalid: The request could not be processed. Please try again.
+ referral_override_not_rejected: Only rejected referrals can be overridden.
diff --git a/src/Resources/translations/flashes.es.yaml b/src/Resources/translations/flashes.es.yaml
new file mode 100644
index 0000000..b95633d
--- /dev/null
+++ b/src/Resources/translations/flashes.es.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: La cuenta de fidelidad ha sido desactivada.
+ account_enabled: La cuenta de fidelidad ha sido activada.
+ adjustment_applied: El ajuste manual se ha escrito en el libro mayor.
+ adjustment_invalid: El ajuste no es válido — los puntos deben ser distintos de cero, la nota es obligatoria y el motivo debe ser uno de los códigos configurados.
+ redemption_applied: Tus puntos se han aplicado al pedido.
+ redemption_invalid_points: Esa cantidad de puntos no se puede aplicar a este pedido.
+ redemption_invalid_request: La solicitud no se pudo procesar. Inténtalo de nuevo.
+ redemption_not_available: El canje de puntos no está disponible en este momento.
+ redemption_removed: Los puntos se han quitado del pedido.
+ referral_overridden: La recomendación ha sido aprobada y recompensada.
+ referral_override_invalid: La solicitud no se pudo procesar. Inténtalo de nuevo.
+ referral_override_not_rejected: Solo las recomendaciones rechazadas se pueden aprobar manualmente.
diff --git a/src/Resources/translations/flashes.fi.yaml b/src/Resources/translations/flashes.fi.yaml
new file mode 100644
index 0000000..02ee8f3
--- /dev/null
+++ b/src/Resources/translations/flashes.fi.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Kanta-asiakastili on poistettu käytöstä.
+ account_enabled: Kanta-asiakastili on otettu käyttöön.
+ adjustment_applied: Manuaalinen oikaisu on kirjattu kirjanpitoon.
+ adjustment_invalid: Oikaisu on virheellinen — pisteiden on oltava nollasta poikkeavat, huomautus on pakollinen ja syyn on oltava jokin määritetyistä koodeista.
+ redemption_applied: Pisteesi on käytetty tilaukseen.
+ redemption_invalid_points: Kyseistä pistemäärää ei voi käyttää tähän tilaukseen.
+ redemption_invalid_request: Pyyntöä ei voitu käsitellä. Yritä uudelleen.
+ redemption_not_available: Pisteiden lunastus ei ole juuri nyt käytettävissä.
+ redemption_removed: Pisteet on poistettu tilauksesta.
+ referral_overridden: Suosittelu on hyväksytty ja palkittu.
+ referral_override_invalid: Pyyntöä ei voitu käsitellä. Yritä uudelleen.
+ referral_override_not_rejected: Vain hylätyt suosittelut voidaan ohittaa.
diff --git a/src/Resources/translations/flashes.fr.yaml b/src/Resources/translations/flashes.fr.yaml
new file mode 100644
index 0000000..d323748
--- /dev/null
+++ b/src/Resources/translations/flashes.fr.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Le compte de fidélité a été désactivé.
+ account_enabled: Le compte de fidélité a été activé.
+ adjustment_applied: 'L''ajustement manuel a été inscrit dans le registre.'
+ adjustment_invalid: 'L''ajustement est invalide — les points doivent être différents de zéro, la note est obligatoire et la raison doit être l''un des codes configurés.'
+ redemption_applied: Vos points ont été appliqués à la commande.
+ redemption_invalid_points: Ce nombre de points ne peut pas être appliqué à cette commande.
+ redemption_invalid_request: 'La demande n''a pas pu être traitée. Veuillez réessayer.'
+ redemption_not_available: 'L''utilisation des points n''est pas disponible pour le moment.'
+ redemption_removed: Les points ont été retirés de la commande.
+ referral_overridden: Le parrainage a été approuvé et récompensé.
+ referral_override_invalid: 'La demande n''a pas pu être traitée. Veuillez réessayer.'
+ referral_override_not_rejected: 'Seuls les parrainages rejetés peuvent faire l''objet d''une dérogation.'
diff --git a/src/Resources/translations/flashes.hu.yaml b/src/Resources/translations/flashes.hu.yaml
new file mode 100644
index 0000000..2fed85b
--- /dev/null
+++ b/src/Resources/translations/flashes.hu.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: A hűségfiók letiltásra került.
+ account_enabled: A hűségfiók engedélyezésre került.
+ adjustment_applied: A kézi módosítás bekerült a főkönyvbe.
+ adjustment_invalid: A módosítás érvénytelen — a pontszám nem lehet nulla, a megjegyzés kötelező, és az indoknak a beállított kódok egyikének kell lennie.
+ redemption_applied: Pontjai felhasználásra kerültek a rendelésen.
+ redemption_invalid_points: Ennyi pont nem használható fel erre a rendelésre.
+ redemption_invalid_request: A kérést nem sikerült feldolgozni. Kérjük, próbálja újra.
+ redemption_not_available: A pontbeváltás jelenleg nem érhető el.
+ redemption_removed: A pontok eltávolításra kerültek a rendelésről.
+ referral_overridden: Az ajánlás jóvá lett hagyva és jutalmazva lett.
+ referral_override_invalid: A kérést nem sikerült feldolgozni. Kérjük, próbálja újra.
+ referral_override_not_rejected: Csak elutasított ajánlások bírálhatók felül.
diff --git a/src/Resources/translations/flashes.it.yaml b/src/Resources/translations/flashes.it.yaml
new file mode 100644
index 0000000..d066d08
--- /dev/null
+++ b/src/Resources/translations/flashes.it.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: 'L''account fedeltà è stato disattivato.'
+ account_enabled: 'L''account fedeltà è stato attivato.'
+ adjustment_applied: La rettifica manuale è stata scritta nel registro.
+ adjustment_invalid: La rettifica non è valida — i punti devono essere diversi da zero, la nota è obbligatoria e il motivo deve essere uno dei codici configurati.
+ redemption_applied: 'I tuoi punti sono stati applicati all''ordine.'
+ redemption_invalid_points: Quel numero di punti non può essere applicato a questo ordine.
+ redemption_invalid_request: La richiesta non è stata elaborata. Riprova.
+ redemption_not_available: Il riscatto dei punti non è al momento disponibile.
+ redemption_removed: 'I punti sono stati rimossi dall''ordine.'
+ referral_overridden: Il referral è stato approvato e premiato.
+ referral_override_invalid: La richiesta non è stata elaborata. Riprova.
+ referral_override_not_rejected: Solo i referral rifiutati possono essere forzati.
diff --git a/src/Resources/translations/flashes.nl.yaml b/src/Resources/translations/flashes.nl.yaml
new file mode 100644
index 0000000..fb2588d
--- /dev/null
+++ b/src/Resources/translations/flashes.nl.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Het loyaliteitsaccount is uitgeschakeld.
+ account_enabled: Het loyaliteitsaccount is ingeschakeld.
+ adjustment_applied: De handmatige correctie is in het grootboek weggeschreven.
+ adjustment_invalid: De correctie is ongeldig — de punten mogen niet nul zijn, de notitie is verplicht en de reden moet een van de geconfigureerde codes zijn.
+ redemption_applied: Je punten zijn op de bestelling toegepast.
+ redemption_invalid_points: Dat aantal punten kan niet op deze bestelling worden toegepast.
+ redemption_invalid_request: Het verzoek kon niet worden verwerkt. Probeer het opnieuw.
+ redemption_not_available: Punten inwisselen is momenteel niet beschikbaar.
+ redemption_removed: De punten zijn van de bestelling verwijderd.
+ referral_overridden: De doorverwijzing is goedgekeurd en beloond.
+ referral_override_invalid: Het verzoek kon niet worden verwerkt. Probeer het opnieuw.
+ referral_override_not_rejected: Alleen afgewezen doorverwijzingen kunnen worden overruled.
diff --git a/src/Resources/translations/flashes.no.yaml b/src/Resources/translations/flashes.no.yaml
new file mode 100644
index 0000000..c09dea5
--- /dev/null
+++ b/src/Resources/translations/flashes.no.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Lojalitetskontoen er deaktivert.
+ account_enabled: Lojalitetskontoen er aktivert.
+ adjustment_applied: Den manuelle justeringen er skrevet til hovedboken.
+ adjustment_invalid: Justeringen er ugyldig — point må være forskjellig fra null, notatet er obligatorisk, og årsaken må være en av de konfigurerte kodene.
+ redemption_applied: Pointene dine er brukt på ordren.
+ redemption_invalid_points: Det antallet point kan ikke brukes på denne ordren.
+ redemption_invalid_request: Forespørselen kunne ikke behandles. Prøv igjen.
+ redemption_not_available: Innløsning av point er ikke tilgjengelig akkurat nå.
+ redemption_removed: Pointene er fjernet fra ordren.
+ referral_overridden: Vervingen er godkjent og belønnet.
+ referral_override_invalid: Forespørselen kunne ikke behandles. Prøv igjen.
+ referral_override_not_rejected: Bare avviste vervinger kan overstyres.
diff --git a/src/Resources/translations/flashes.pl.yaml b/src/Resources/translations/flashes.pl.yaml
new file mode 100644
index 0000000..9873801
--- /dev/null
+++ b/src/Resources/translations/flashes.pl.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Konto lojalnościowe zostało wyłączone.
+ account_enabled: Konto lojalnościowe zostało włączone.
+ adjustment_applied: Korekta ręczna została zapisana w księdze.
+ adjustment_invalid: Korekta jest nieprawidłowa — liczba punktów musi być różna od zera, notatka jest obowiązkowa, a powód musi być jednym ze skonfigurowanych kodów.
+ redemption_applied: Twoje punkty zostały zastosowane do zamówienia.
+ redemption_invalid_points: Takiej liczby punktów nie można zastosować do tego zamówienia.
+ redemption_invalid_request: Nie udało się przetworzyć żądania. Spróbuj ponownie.
+ redemption_not_available: Wymiana punktów jest obecnie niedostępna.
+ redemption_removed: Punkty zostały usunięte z zamówienia.
+ referral_overridden: Polecenie zostało zatwierdzone i nagrodzone.
+ referral_override_invalid: Nie udało się przetworzyć żądania. Spróbuj ponownie.
+ referral_override_not_rejected: Tylko odrzucone polecenia można zatwierdzić mimo to.
diff --git a/src/Resources/translations/flashes.pt.yaml b/src/Resources/translations/flashes.pt.yaml
new file mode 100644
index 0000000..04fac00
--- /dev/null
+++ b/src/Resources/translations/flashes.pt.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: A conta de fidelidade foi desativada.
+ account_enabled: A conta de fidelidade foi ativada.
+ adjustment_applied: O ajuste manual foi registado no livro-razão.
+ adjustment_invalid: O ajuste é inválido — os pontos devem ser diferentes de zero, a nota é obrigatória e o motivo deve ser um dos códigos configurados.
+ redemption_applied: Os seus pontos foram aplicados à encomenda.
+ redemption_invalid_points: Esse número de pontos não pode ser aplicado a esta encomenda.
+ redemption_invalid_request: Não foi possível processar o pedido. Tente novamente.
+ redemption_not_available: O resgate de pontos não está disponível neste momento.
+ redemption_removed: Os pontos foram removidos da encomenda.
+ referral_overridden: A indicação foi aprovada e recompensada.
+ referral_override_invalid: Não foi possível processar o pedido. Tente novamente.
+ referral_override_not_rejected: Apenas as indicações rejeitadas podem ser aprovadas manualmente.
diff --git a/src/Resources/translations/flashes.ro.yaml b/src/Resources/translations/flashes.ro.yaml
new file mode 100644
index 0000000..6e6fdf2
--- /dev/null
+++ b/src/Resources/translations/flashes.ro.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Contul de fidelitate a fost dezactivat.
+ account_enabled: Contul de fidelitate a fost activat.
+ adjustment_applied: Ajustarea manuală a fost înregistrată în registru.
+ adjustment_invalid: Ajustarea este invalidă — punctele trebuie să fie diferite de zero, nota este obligatorie, iar motivul trebuie să fie unul dintre codurile configurate.
+ redemption_applied: Punctele dumneavoastră au fost aplicate comenzii.
+ redemption_invalid_points: Acest număr de puncte nu poate fi aplicat acestei comenzi.
+ redemption_invalid_request: Cererea nu a putut fi procesată. Vă rugăm să încercați din nou.
+ redemption_not_available: Utilizarea punctelor nu este disponibilă momentan.
+ redemption_removed: Punctele au fost eliminate din comandă.
+ referral_overridden: Recomandarea a fost aprobată și recompensată.
+ referral_override_invalid: Cererea nu a putut fi procesată. Vă rugăm să încercați din nou.
+ referral_override_not_rejected: Doar recomandările respinse pot fi aprobate manual.
diff --git a/src/Resources/translations/flashes.sv.yaml b/src/Resources/translations/flashes.sv.yaml
new file mode 100644
index 0000000..658c3a4
--- /dev/null
+++ b/src/Resources/translations/flashes.sv.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Lojalitetskontot har inaktiverats.
+ account_enabled: Lojalitetskontot har aktiverats.
+ adjustment_applied: Den manuella justeringen har skrivits till huvudboken.
+ adjustment_invalid: Justeringen är ogiltig — poängen måste vara skilda från noll, anteckningen är obligatorisk och orsaken måste vara en av de konfigurerade koderna.
+ redemption_applied: Dina poäng har tillämpats på ordern.
+ redemption_invalid_points: Det antalet poäng kan inte tillämpas på denna order.
+ redemption_invalid_request: Begäran kunde inte behandlas. Försök igen.
+ redemption_not_available: Inlösen av poäng är inte tillgänglig just nu.
+ redemption_removed: Poängen har tagits bort från ordern.
+ referral_overridden: Värvningen har godkänts och belönats.
+ referral_override_invalid: Begäran kunde inte behandlas. Försök igen.
+ referral_override_not_rejected: Endast avvisade värvningar kan åsidosättas.
diff --git a/src/Resources/translations/flashes.uk.yaml b/src/Resources/translations/flashes.uk.yaml
new file mode 100644
index 0000000..4ec6b3a
--- /dev/null
+++ b/src/Resources/translations/flashes.uk.yaml
@@ -0,0 +1,14 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ account_disabled: Рахунок лояльності вимкнено.
+ account_enabled: Рахунок лояльності увімкнено.
+ adjustment_applied: Ручне коригування записано до журналу.
+ adjustment_invalid: Коригування некоректне — кількість балів має бути ненульовою, примітка обовʼязкова, а причина має бути одним із налаштованих кодів.
+ redemption_applied: Ваші бали застосовано до замовлення.
+ redemption_invalid_points: Таку кількість балів неможливо застосувати до цього замовлення.
+ redemption_invalid_request: Не вдалося обробити запит. Спробуйте ще раз.
+ redemption_not_available: Списання балів наразі недоступне.
+ redemption_removed: Бали прибрано із замовлення.
+ referral_overridden: Рекомендацію схвалено та винагороджено.
+ referral_override_invalid: Не вдалося обробити запит. Спробуйте ще раз.
+ referral_override_not_rejected: Вручну схвалити можна лише відхилені рекомендації.
diff --git a/src/Resources/translations/messages.cs.yaml b/src/Resources/translations/messages.cs.yaml
new file mode 100644
index 0000000..8d051ed
--- /dev/null
+++ b/src/Resources/translations/messages.cs.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Přidat podmínku
+ amount_configuration: Konfigurace částky
+ amount_configuration_help: 'JSON, např. {"points": 1, "per_amount": 100} pro body za částku, {"points": 50} pro pevný počet, {"factor": 2} pro násobitel, {"expression": "floor(basis / 100)"} pro výraz'
+ amount_expression: Výraz částky
+ amount_expression_help: Použije se, když je typ částky „Výraz“; proměnná "basis" obsahuje základ nárokovaný pravidlem v nejmenších měnových jednotkách
+ amount_requires_order_trigger: Typy za částku a násobitel potřebují základ objednávky a jsou dostupné pouze pro vestavěný spouštěč objednávky.
+ amount_type: Typ částky
+ condition_configuration: Konfigurace podmínky
+ condition_configuration_help: 'JSON, např. {"amount": 5000} nebo {"taxons": ["t_shirts"]}'
+ condition_expression: Výraz podmínky
+ condition_expression_help: Použije se, když je typ podmínky „Výraz“; podmínka projde, když je výraz pravdivý
+ condition_type: Typ podmínky
+ expression_required: Režim výrazu vyžaduje výraz.
+ multiplier_is_order_scoped: Pravidla s násobitelem platí pouze pro celou objednávku; pro logiku na úrovni položek použijte režim výrazu.
+ scope_requires_order_trigger: Rozsahy podle taxonu a produktu potřebují položky objednávky a jsou dostupné pouze pro vestavěný spouštěč objednávky.
+ conditions: Podmínky
+ conditions_match: Splnění podmínek
+ conditions_match_all: Všechny podmínky musí projít
+ conditions_match_any: Alespoň jedna podmínka musí projít
+ dry_run: Zkušební režim
+ dry_run_help: Vyhodnocovat na živém provozu a zapisovat do auditního seznamu, co by bylo přiděleno, aniž by se připsaly jakékoli body
+ ends_at: Končí
+ scope: Rozsah
+ scope_configuration: Konfigurace rozsahu
+ scope_configuration_help: 'JSON, např. {"taxons": ["t_shirts"]} nebo {"products": ["MUG"]}; pro rozsah celé objednávky ponechte prázdné'
+ scope_help: Pravidla s rozsahem produktu nebo taxonu si výhradně nárokují odpovídající položky; pravidla s rozsahem objednávky získávají body ze zbytku
+ scope_order: Celá objednávka
+ scope_product: Konkrétní produkty
+ scope_taxon: Konkrétní taxony
+ stackable: Kombinovatelné
+ stackable_help: Kombinovatelná pravidla soutěžící o stejný základ se uplatní všechna a sečtou se; nekombinovatelné pravidlo se uplatní samo (vyhrává nejvyšší priorita)
+ starts_at: Začíná
+ trigger: Spouštěč
+ window_timezone_help: Zadává a vyhodnocuje se v časovém pásmu nastaveném v aplikaci
+ earning_rule.amount:
+ expression: Výraz
+ fixed: Pevný počet bodů
+ multiplier: Násobitel
+ per_amount: Body za částku
+ earning_rule.condition:
+ cart_contains_taxon: Košík obsahuje taxon
+ customer_group: Skupina zákazníků
+ date_window: Časové okno
+ day_of_week: Den v týdnu
+ expression: Výraz
+ nth_order: N-tá objednávka
+ order_total_at_least: Celková hodnota objednávky alespoň
+ invalid_json: Tato hodnota není platný JSON.
+ program:
+ award_moment_order_fulfilled: Když je objednávka vyřízena
+ award_moment_payment_paid: Když je platba plně uhrazena
+ award_order_points_at: Body za objednávku přidělit při
+ award_order_points_at_help: Jediný okamžik životního cyklu objednávky, kdy se body přidělují — jeden na program, nikdy na pravidlo
+ base_currency_help: Částky jsou v nejmenších jednotkách základní měny kanálu
+ clawback_policy: Politika zpětného odebrání
+ clawback_policy_allow_negative: Povolit záporný zůstatek (vyrovná se z budoucích zisků)
+ clawback_policy_clamp_to_zero: Omezit zůstatek na nulu
+ earning_basis: Základ pro získávání
+ earning_basis_help: Co se počítá do získávání; samotnou sazbu získávání definují pravidla získávání
+ earning_basis_items_total: Součet položek (bez dopravy)
+ earning_basis_order_total: Celková hodnota objednávky (vč. dopravy)
+ include_taxes: Zahrnout daně do základu
+ max_redeem_percent_of_order: Max. uplatnitelné procento objednávky
+ max_redeem_percent_of_order_help: Podíl součtu položek objednávky, který lze pokrýt body (100 umožňuje zcela bezplatné objednávky)
+ min_redeem_points: Minimální počet bodů na uplatnění
+ points_expiry_days: Body vyprší po (dnech)
+ points_expiry_days_help: Ponechte prázdné, aby body nikdy nevypršely. Upozorňujeme, že vypršení uložené hodnoty podléhá v některých jurisdikcích omezením spotřebitelského práva
+ redemption_conversion_amount: '... mají hodnotu této částky (nejmenší jednotky)'
+ redemption_conversion_help: 'Převod při uplatnění: tento počet bodů...'
+ redemption_conversion_points: Jednotka bodů
+ retroactive_guest_points: Zpětné body hostů
+ retroactive_guest_points_help: Přidělit body za objednávky hosta před registrací, když se host zaregistruje (bez zpětného doplnění pro dřívější registrace)
+ rounding: Zaokrouhlování
+ show_earnable_in_cart: Zobrazit nápovědu k bodům v košíku
+ show_earnable_on_product: Zobrazit nápovědu k bodům na stránkách produktů
+ tier_downgrade_grace_days: Ochranná lhůta před snížením úrovně (dny)
+ tier_downgrade_grace_days_help: Jak dlouho si účet ponechá svou úroveň po poklesu pod prahovou hodnotu; 0 snižuje úroveň při příštím nočním vyhodnocení
+ tier_evaluation_window: Vyhodnocovací období úrovní
+ tier_window_calendar_year: Kalendářní rok
+ tier_window_lifetime: Za celou dobu
+ tier_window_rolling_12_months: Klouzavých 12 měsíců
+ rounding_ceil: Nahoru
+ rounding_floor: Dolů
+ rounding_round: Matematicky
+ promotion_rule:
+ customer_loyalty_tier: Věrnostní úroveň zákazníka je alespoň
+ minimum_tier: Minimální úroveň
+ tier:
+ benefits: Výhody
+ benefits_description: Popis výhod
+ color: Barva odznaku
+ earning_multiplier: Násobitel získávání
+ earning_multiplier_help: Použije se na každé získání bodů, dokud je účet na této úrovni (1 = beze změny)
+ position_help: Vyšší pozice jsou vyšší úrovně; nejvyšší pozice je nejvyšší úroveň
+ qualification_basis: Základ kvalifikace
+ threshold: Prahová hodnota
+ threshold_help: Jednotka závisí na základu (body, nejmenší jednotky měny nebo objednávky)
+ tier_basis:
+ amount_spent: Utracená částka
+ orders_count: Provedené objednávky
+ points_earned: Získané body
+ unit:
+ currency: nejmenší jednotky měny
+ orders: objednávky
+ points: body
+ trigger:
+ customer_birthday: Narozeniny zákazníka
+ customer_registered: Zákazník se zaregistroval
+ order_eligible: Objednávka (vestavěné)
+ product_review_approved: Recenze produktu schválena
+ ui:
+ account: Věrnostní účet
+ account_inactive: Váš věrnostní účet je momentálně neaktivní — váš zůstatek je zachován, ale získávání a uplatňování bodů je pozastaveno.
+ accounts: Věrnostní účty
+ accounts_card_description: Procházení účtů, kontrola knih transakcí, úpravy bodů
+ adjust: Upravit
+ anonymized: Anonymizováno
+ balance: Váš zůstatek bodů
+ balance_label: Zůstatek
+ change_on_cart: Změnit v košíku
+ consumptions: Spotřebováno
+ dashboard_subtitle: Body, pravidla získávání a nastavení programu
+ derived_balance: Součet knihy transakcí
+ dry_run: Zkušební režim
+ dry_run_results: Výsledky zkušebního režimu
+ dry_run_results_card_description: Co by pravidla ve zkušebním režimu přidělila
+ earn_hint_cart: 'Za tuto objednávku získáte ~%points% bodů.'
+ earn_hint_product: 'Nákupem tohoto produktu získáte %points% bodů.'
+ earning_rate_hint: 'Sazbu získávání definují pravidla získávání: základní pravidlo s rozsahem objednávky a typem za částku je výchozí sazbou obchodu.'
+ earning_rules: Pravidla získávání
+ earning_rules_card_description: Spouštěče, podmínky a částky
+ edit_earning_rule: Upravit pravidlo získávání
+ edit_program: Upravit program
+ new_earning_rule: Nové pravidlo získávání
+ description: Popis
+ expired: Vypršelo
+ expires: 'vyprší %date%'
+ expires_at: Vyprší
+ expiring_soon: '%points% bodů vyprší před %date%.'
+ history: Historie transakcí
+ history_description:
+ clawback: 'Body odebrané za zrušenou nebo refundovanou objednávku'
+ earn_action: 'Získané body'
+ earn_order: 'Získáno za objednávku %order%'
+ earn_referral: 'Odměna za doporučení'
+ expire: 'Body vypršely'
+ manual_credit: 'Úprava: %reason%'
+ manual_debit: 'Úprava: %reason%'
+ redeem: 'Uplatněno na objednávku %order%'
+ redeem_rollback: 'Body vrácené ze zrušené objednávky'
+ history_empty: Zatím žádné transakce — body, které získáte a utratíte, se zobrazí zde.
+ my_loyalty: Můj věrnostní program
+ running_balance: Zůstatek
+ inspect: Prozkoumat
+ invariants_hold: Všechny invarianty knihy transakcí platí.
+ invariants_violated: Invarianty knihy transakcí jsou porušeny — před ručními opravami proveďte šetření.
+ ledger: Kniha transakcí
+ lifetime_earned: Celkem získáno
+ lot: Dávka
+ lots: Dávky (odvozené přehráním)
+ loyalty: Věrnostní program
+ manual_adjustment: Ruční úprava
+ manual_reason:
+ correction: Oprava
+ goodwill: Vstřícnost
+ other: Jiné
+ promotion: Promoakce
+ never: nikdy
+ note: Poznámka
+ points: Body
+ points_amount: '%points% bodů'
+ points_applied: 'Uplatněno %points% bodů'
+ points_redemption: Uplatnění bodů
+ program: Program
+ program_card_description: Převod, vypršení a zásady pro jednotlivé kanály
+ reason: Důvod
+ refer_a_friend: Doporučte přítele
+ referee: Doporučený
+ referral_explainer: Sdílejte svůj odkaz — když přítel provede svou první objednávku, body získáte oba.
+ referral_override: Přesto schválit
+ referral_stats: 'Máte %count% odměněných doporučení, která vynesla %points% bodů.'
+ referral_status:
+ expired: Vypršelo
+ pending: Čeká
+ qualified: Kvalifikované
+ rejected: Zamítnuté
+ rewarded: Odměněné
+ referrals: Doporučení
+ referrals_card_description: Stavy doporučení a ruční schválení při podezření na podvod
+ referrer: Doporučitel
+ redemption_clamped: 'Váš požadavek na %requested% bodů je na tuto objednávku uplatněn jen částečně; automaticky se opět navýší, jakmile to objednávka umožní.'
+ remaining: Zbývá
+ remove_redemption: Odebrat
+ rule: Pravidlo
+ rule_tester: Tester pravidel
+ rule_tester_card_description: Vyhodnotit pravidla na nedávné objednávce, volitelně k jinému datu
+ scope: Rozsah
+ share: Sdílet
+ copied: Zkopírováno!
+ stat_accounts: Účty
+ stat_active_accounts_90_days: Aktivní účty (90 dní)
+ stat_liability: Nesplacený závazek z bodů
+ stat_redemption_rate_90_days: Míra uplatnění (90 dní)
+ stat_earned_30_days: Získané body (30 dní)
+ stat_redeemed_30_days: Uplatněné body (30 dní)
+ tester_applied: Uplatněno
+ tester_claimed_basis: Nárokovaný základ
+ tester_claimed_by: Nárokováno
+ tester_evaluate: Vyhodnotit
+ tester_evaluate_at: Vyhodnotit, jako by bylo
+ tester_evaluate_at_help: Náhled naplánovaných pravidel před otevřením jejich okna
+ tester_failed_conditions: Nesplněné podmínky
+ tester_final_award: 'Konečné přidělení: %points% bodů'
+ tester_invalid_date: Datum vyhodnocení je neplatné.
+ tester_item_claims: Nároky podle položek
+ tester_matched: Odpovídá
+ tester_order_has_no_customer: Objednávka nemá zákazníka, takže nelze vyhodnotit žádná pravidla.
+ tester_order_not_found: Objednávka s tímto číslem nebyla nalezena.
+ tester_order_number: Číslo objednávky
+ tester_rules: Vyhodnocení pravidel
+ tier: Úroveň
+ tier_progress: '%metric% / %threshold% do %tier%'
+ tier_top: Dosáhli jste nejvyšší úrovně — děkujeme, že patříte mezi naše úplně nejlepší zákazníky!
+ tiers: Úrovně
+ tiers_card_description: Úrovně, prahové hodnoty, násobitele a výhody
+ trigger: Spouštěč
+ use_max: Použít maximum
+ your_code: Váš kód
+ use_your_points: Využijte své body
diff --git a/src/Resources/translations/messages.da.yaml b/src/Resources/translations/messages.da.yaml
new file mode 100644
index 0000000..6e8569e
--- /dev/null
+++ b/src/Resources/translations/messages.da.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Tilføj betingelse
+ amount_configuration: Beløbskonfiguration
+ amount_configuration_help: 'JSON, f.eks. {"points": 1, "per_amount": 100} for pr. beløb, {"points": 50} for fast, {"factor": 2} for multiplikator, {"expression": "floor(basis / 100)"} for udtryk'
+ amount_expression: Beløbsudtryk
+ amount_expression_help: Bruges, når beløbstypen er "Udtryk"; variablen "basis" indeholder den basis, reglen gør krav på, i mindste enheder
+ amount_requires_order_trigger: Typerne pr. beløb og multiplikator kræver en ordrebasis og er kun tilgængelige for den indbyggede ordre-trigger.
+ amount_type: Beløbstype
+ condition_configuration: Betingelseskonfiguration
+ condition_configuration_help: 'JSON, f.eks. {"amount": 5000} eller {"taxons": ["t_shirts"]}'
+ condition_expression: Betingelsesudtryk
+ condition_expression_help: Bruges, når betingelsestypen er "Udtryk"; betingelsen er opfyldt, når udtrykket evalueres som sandt
+ condition_type: Betingelsestype
+ expression_required: Udtrykstilstand kræver et udtryk.
+ multiplier_is_order_scoped: Multiplikatorregler gælder kun for hele ordren; brug udtrykstilstand til logik på varelinjeniveau.
+ scope_requires_order_trigger: Taxon- og produktafgrænsninger kræver ordrelinjer og er kun tilgængelige for den indbyggede ordre-trigger.
+ conditions: Betingelser
+ conditions_match: Match af betingelser
+ conditions_match_all: Alle betingelser skal være opfyldt
+ conditions_match_any: Mindst én betingelse skal være opfyldt
+ dry_run: Testkørsel
+ dry_run_help: Evaluer mod den faktiske trafik og log til revisionslisten, hvad der ville være blevet tildelt, uden at skrive nogen point
+ ends_at: Slutter
+ scope: Afgrænsning
+ scope_configuration: Afgrænsningskonfiguration
+ scope_configuration_help: 'JSON, f.eks. {"taxons": ["t_shirts"]} eller {"products": ["MUG"]}; lad feltet stå tomt for hele ordren'
+ scope_help: Produkt- eller taxonafgrænsede regler gør eksklusivt krav på deres matchende varer; ordreafgrænsede regler optjener på resten
+ scope_order: Hele ordren
+ scope_product: Bestemte produkter
+ scope_taxon: Bestemte taxoner
+ stackable: Kan kombineres
+ stackable_help: Regler, der kan kombineres og konkurrerer om samme basis, gælder alle og lægges sammen; en regel, der ikke kan kombineres, gælder alene (højeste prioritet vinder)
+ starts_at: Starter
+ trigger: Trigger
+ window_timezone_help: Indtastes og evalueres i applikationens konfigurerede tidszone
+ earning_rule.amount:
+ expression: Udtryk
+ fixed: Faste point
+ multiplier: Multiplikator
+ per_amount: Point pr. beløb
+ earning_rule.condition:
+ cart_contains_taxon: Kurven indeholder taxon
+ customer_group: Kundegruppe
+ date_window: Datointerval
+ day_of_week: Ugedag
+ expression: Udtryk
+ nth_order: N'te ordre
+ order_total_at_least: Ordretotal mindst
+ invalid_json: Denne værdi er ikke gyldig JSON.
+ program:
+ award_moment_order_fulfilled: Når ordren er gennemført
+ award_moment_payment_paid: Når betalingen er fuldt betalt
+ award_order_points_at: Tidspunkt for tildeling af ordrepoint
+ award_order_points_at_help: Det ene tidspunkt i ordreforløbet, hvor point tildeles — ét pr. program, aldrig pr. regel
+ base_currency_help: Beløb angives i mindste enheder af kanalens basisvaluta
+ clawback_policy: Tilbageførselspolitik
+ clawback_policy_allow_negative: Tillad negativ saldo (inddrives fra fremtidig optjening)
+ clawback_policy_clamp_to_zero: Begræns saldoen til nul
+ earning_basis: Optjeningsgrundlag
+ earning_basis_help: Hvad der tæller med i optjeningen; selve optjeningssatsen defineres af optjeningsregler
+ earning_basis_items_total: Varetotal (ekskl. fragt)
+ earning_basis_order_total: Ordretotal (inkl. fragt)
+ include_taxes: Medregn moms i grundlaget
+ max_redeem_percent_of_order: Maks. indløselig procent af ordren
+ max_redeem_percent_of_order_help: Den andel af ordrens varetotal, der kan dækkes af point (100 tillader helt gratis ordrer)
+ min_redeem_points: Minimum point pr. indløsning
+ points_expiry_days: Point udløber efter (dage)
+ points_expiry_days_help: Lad feltet stå tomt, så point aldrig udløber. Bemærk, at udløb af opsparet værdi kan være underlagt forbrugerretlige begrænsninger i nogle jurisdiktioner
+ redemption_conversion_amount: '... er dette beløb værd (mindste enheder)'
+ redemption_conversion_help: 'Indløsningskursen: så mange point...'
+ redemption_conversion_points: Pointenhed
+ retroactive_guest_points: Point for tidligere gæsteordrer
+ retroactive_guest_points_help: Tildel point for gæsteordrer afgivet før registrering, når gæsten registrerer sig (ingen efterbetaling for tidligere registreringer)
+ rounding: Afrunding
+ show_earnable_in_cart: Vis optjeningshint i kurven
+ show_earnable_on_product: Vis optjeningshint på produktsider
+ tier_downgrade_grace_days: Henstand ved niveaunedrykning (dage)
+ tier_downgrade_grace_days_help: Hvor længe en konto beholder sit niveau efter at være faldet under tærsklen; 0 nedrykker ved næste natlige evaluering
+ tier_evaluation_window: Evalueringsvindue for niveauer
+ tier_window_calendar_year: Kalenderår
+ tier_window_lifetime: Levetid
+ tier_window_rolling_12_months: Rullende 12 måneder
+ rounding_ceil: Rund op
+ rounding_floor: Rund ned
+ rounding_round: Afrund
+ promotion_rule:
+ customer_loyalty_tier: Kundens loyalitetsniveau er mindst
+ minimum_tier: Minimumsniveau
+ tier:
+ benefits: Fordele
+ benefits_description: Beskrivelse af fordele
+ color: Badgefarve
+ earning_multiplier: Optjeningsmultiplikator
+ earning_multiplier_help: Anvendes på al pointoptjening, mens kontoen er på dette niveau (1 = ingen ændring)
+ position_help: Højere positioner er højere niveauer; den højeste position er topniveauet
+ qualification_basis: Kvalifikationsgrundlag
+ threshold: Tærskel
+ threshold_help: Enheden afhænger af grundlaget (point, mindste valutaenheder eller ordrer)
+ tier_basis:
+ amount_spent: Forbrugt beløb
+ orders_count: Afgivne ordrer
+ points_earned: Optjente point
+ unit:
+ currency: mindste valutaenheder
+ orders: ordrer
+ points: point
+ trigger:
+ customer_birthday: Kundens fødselsdag
+ customer_registered: Kunde registreret
+ order_eligible: Ordre (indbygget)
+ product_review_approved: Produktanmeldelse godkendt
+ ui:
+ account: Loyalitetskonto
+ account_inactive: Din loyalitetskonto er i øjeblikket inaktiv — din saldo er bevaret, men optjening og indløsning er sat på pause.
+ accounts: Loyalitetskonti
+ accounts_card_description: Gennemse konti, inspicér hovedbøger, justér point
+ adjust: Justér
+ anonymized: Anonymiseret
+ balance: Din pointsaldo
+ balance_label: Saldo
+ change_on_cart: Ændr i kurven
+ consumptions: Forbrugt af
+ dashboard_subtitle: Point, optjeningsregler og programindstillinger
+ derived_balance: Sum i hovedbogen
+ dry_run: Testkørsel
+ dry_run_results: Testkørselsresultater
+ dry_run_results_card_description: Hvad testkørselsregler ville have tildelt
+ earn_hint_cart: 'Denne ordre optjener ~%points% point.'
+ earn_hint_product: 'Du optjener %points% point ved køb af dette produkt.'
+ earning_rate_hint: 'Optjeningssatsen defineres af optjeningsregler: en basisregel for hele ordren med typen pr. beløb er butikkens standardsats.'
+ earning_rules: Optjeningsregler
+ earning_rules_card_description: Triggere, betingelser og beløb
+ edit_earning_rule: Redigér optjeningsregel
+ edit_program: Redigér program
+ new_earning_rule: Ny optjeningsregel
+ description: Beskrivelse
+ expired: Udløbet
+ expires: 'udløber %date%'
+ expires_at: Udløber
+ expiring_soon: '%points% point udløber inden %date%.'
+ history: Transaktionshistorik
+ history_description:
+ clawback: 'Point tilbageført for en annulleret eller refunderet ordre'
+ earn_action: 'Point optjent'
+ earn_order: 'Optjent på ordre %order%'
+ earn_referral: 'Henvisningsbelønning'
+ expire: 'Point udløbet'
+ manual_credit: 'Justering: %reason%'
+ manual_debit: 'Justering: %reason%'
+ redeem: 'Indløst på ordre %order%'
+ redeem_rollback: 'Point gendannet fra en annulleret ordre'
+ history_empty: Ingen transaktioner endnu — point, du optjener og bruger, vises her.
+ my_loyalty: Mit loyalitetsprogram
+ running_balance: Saldo
+ inspect: Inspicér
+ invariants_hold: Alle hovedbogens invarianter holder.
+ invariants_violated: Hovedbogens invarianter er brudt — undersøg sagen, før der foretages manuelle rettelser.
+ ledger: Hovedbog
+ lifetime_earned: Optjent i alt
+ lot: Lot
+ lots: Lots (afledt ved genafspilning)
+ loyalty: Loyalitet
+ manual_adjustment: Manuel justering
+ manual_reason:
+ correction: Rettelse
+ goodwill: Goodwill
+ other: Andet
+ promotion: Kampagne
+ never: aldrig
+ note: Notat
+ points: Point
+ points_amount: '%points% point'
+ points_applied: '%points% point anvendt'
+ points_redemption: Pointindløsning
+ program: Program
+ program_card_description: Konvertering, udløb og politikker pr. kanal
+ reason: Årsag
+ refer_a_friend: Henvis en ven
+ referee: Henvist
+ referral_explainer: Del dit link — når en ven afgiver sin første ordre, optjener I begge point.
+ referral_override: Godkend alligevel
+ referral_stats: 'Du har %count% belønnede henvisning(er), som har givet %points% point.'
+ referral_status:
+ expired: Udløbet
+ pending: Afventer
+ qualified: Kvalificeret
+ rejected: Afvist
+ rewarded: Belønnet
+ referrals: Henvisninger
+ referrals_card_description: Henvisningsstatusser og tilsidesættelse af svindeltjek
+ referrer: Henviser
+ redemption_clamped: 'Din anmodning om %requested% point er kun delvist anvendt på denne ordre; den øges automatisk igen, når ordren tillader det.'
+ remaining: Resterende
+ remove_redemption: Fjern
+ rule: Regel
+ rule_tester: Regeltester
+ rule_tester_card_description: Evaluer regler mod en nylig ordre, eventuelt på en anden dato
+ scope: Afgrænsning
+ share: Del
+ copied: Kopieret!
+ stat_accounts: Konti
+ stat_active_accounts_90_days: Aktive konti (90 dage)
+ stat_liability: Udestående pointforpligtelse
+ stat_redemption_rate_90_days: Indløsningsrate (90 dage)
+ stat_earned_30_days: Point optjent (30 dage)
+ stat_redeemed_30_days: Point indløst (30 dage)
+ tester_applied: Anvendt
+ tester_claimed_basis: Krævet basis
+ tester_claimed_by: Krævet af
+ tester_evaluate: Evaluer
+ tester_evaluate_at: Evaluer som om det var
+ tester_evaluate_at_help: Se planlagte regler, før deres tidsvindue åbner
+ tester_failed_conditions: Ikke-opfyldte betingelser
+ tester_final_award: 'Endelig tildeling: %points% point'
+ tester_invalid_date: Evalueringsdatoen er ugyldig.
+ tester_item_claims: Krav pr. varelinje
+ tester_matched: Matchet
+ tester_order_has_no_customer: Ordren har ingen kunde, så ingen regler kan evalueres.
+ tester_order_not_found: Der blev ikke fundet nogen ordre med det nummer.
+ tester_order_number: Ordrenummer
+ tester_rules: Regelevalueringer
+ tier: Niveau
+ tier_progress: '%metric% / %threshold% til %tier%'
+ tier_top: Du har nået det højeste niveau — tak, fordi du er en af vores allerbedste kunder!
+ tiers: Niveauer
+ tiers_card_description: Niveauer, tærskler, multiplikatorer og fordele
+ trigger: Trigger
+ use_max: Brug maks.
+ your_code: Din kode
+ use_your_points: Brug dine point
diff --git a/src/Resources/translations/messages.de.yaml b/src/Resources/translations/messages.de.yaml
new file mode 100644
index 0000000..ed80fe6
--- /dev/null
+++ b/src/Resources/translations/messages.de.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Bedingung hinzufügen
+ amount_configuration: Betragskonfiguration
+ amount_configuration_help: 'JSON, z. B. {"points": 1, "per_amount": 100} für "pro Betrag", {"points": 50} für fest, {"factor": 2} für Multiplikator, {"expression": "floor(basis / 100)"} für Ausdruck'
+ amount_expression: Betragsausdruck
+ amount_expression_help: Wird verwendet, wenn der Betragstyp "Ausdruck" ist; die Variable "basis" enthält die von der Regel beanspruchte Basis in kleinsten Währungseinheiten
+ amount_requires_order_trigger: Die Typen "pro Betrag" und "Multiplikator" benötigen eine Bestellbasis und sind nur für den eingebauten Bestell-Trigger verfügbar.
+ amount_type: Betragstyp
+ condition_configuration: Bedingungskonfiguration
+ condition_configuration_help: 'JSON, z. B. {"amount": 5000} oder {"taxons": ["t_shirts"]}'
+ condition_expression: Bedingungsausdruck
+ condition_expression_help: Wird verwendet, wenn der Bedingungstyp "Ausdruck" ist; die Bedingung ist erfüllt, wenn der Ausdruck wahr ist
+ condition_type: Bedingungstyp
+ expression_required: Der Ausdrucksmodus benötigt einen Ausdruck.
+ multiplier_is_order_scoped: Multiplikatorregeln gelten nur für die gesamte Bestellung; verwenden Sie den Ausdrucksmodus für Logik auf Positionsebene.
+ scope_requires_order_trigger: Taxon- und Produktgeltungsbereiche benötigen Bestellpositionen und sind nur für den eingebauten Bestell-Trigger verfügbar.
+ conditions: Bedingungen
+ conditions_match: Bedingungsverknüpfung
+ conditions_match_all: Alle Bedingungen müssen erfüllt sein
+ conditions_match_any: Mindestens eine Bedingung muss erfüllt sein
+ dry_run: Testlauf
+ dry_run_help: Wertet gegen den Live-Traffic aus und protokolliert in der Audit-Liste, was vergeben worden wäre, ohne Punkte zu schreiben
+ ends_at: Endet am
+ scope: Geltungsbereich
+ scope_configuration: Geltungsbereichskonfiguration
+ scope_configuration_help: 'JSON, z. B. {"taxons": ["t_shirts"]} oder {"products": ["MUG"]}; leer lassen für den Bestellgeltungsbereich'
+ scope_help: Produkt- oder taxonbezogene Regeln beanspruchen ihre passenden Positionen exklusiv; bestellbezogene Regeln sammeln auf dem Rest
+ scope_order: Gesamte Bestellung
+ scope_product: Bestimmte Produkte
+ scope_taxon: Bestimmte Taxone
+ stackable: Kombinierbar
+ stackable_help: Kombinierbare Regeln, die um dieselbe Basis konkurrieren, gelten alle und werden summiert; eine nicht kombinierbare Regel gilt allein (höchste Priorität gewinnt)
+ starts_at: Beginnt am
+ trigger: Trigger
+ window_timezone_help: Wird in der konfigurierten Zeitzone der Anwendung eingegeben und ausgewertet
+ earning_rule.amount:
+ expression: Ausdruck
+ fixed: Feste Punkte
+ multiplier: Multiplikator
+ per_amount: Punkte pro Betrag
+ earning_rule.condition:
+ cart_contains_taxon: Warenkorb enthält Taxon
+ customer_group: Kundengruppe
+ date_window: Datumsfenster
+ day_of_week: Wochentag
+ expression: Ausdruck
+ nth_order: N-te Bestellung
+ order_total_at_least: Bestellsumme mindestens
+ invalid_json: Dieser Wert ist kein gültiges JSON.
+ program:
+ award_moment_order_fulfilled: Wenn die Bestellung abgeschlossen ist
+ award_moment_payment_paid: Wenn die Zahlung vollständig bezahlt ist
+ award_order_points_at: Zeitpunkt der Bestellpunktevergabe
+ award_order_points_at_help: Der eine Moment im Bestelllebenszyklus, in dem Punkte vergeben werden — einer pro Programm, nie pro Regel
+ base_currency_help: Beträge sind in kleinsten Währungseinheiten der Basiswährung des Kanals angegeben
+ clawback_policy: Rückbuchungsrichtlinie
+ clawback_policy_allow_negative: Negativen Saldo zulassen (wird mit künftigen Gutschriften verrechnet)
+ clawback_policy_clamp_to_zero: Saldo auf null begrenzen
+ earning_basis: Sammelbasis
+ earning_basis_help: Was für das Sammeln zählt; die Sammelrate selbst wird durch Sammelregeln definiert
+ earning_basis_items_total: Artikelsumme (ohne Versand)
+ earning_basis_order_total: Bestellsumme (inkl. Versand)
+ include_taxes: Steuern in die Basis einbeziehen
+ max_redeem_percent_of_order: Max. einlösbarer Prozentsatz der Bestellung
+ max_redeem_percent_of_order_help: Der Anteil der Artikelsumme der Bestellung, der mit Punkten gedeckt werden kann (100 erlaubt komplett kostenlose Bestellungen)
+ min_redeem_points: Mindestpunkte pro Einlösung
+ points_expiry_days: Punkte verfallen nach (Tagen)
+ points_expiry_days_help: Leer lassen, damit Punkte nie verfallen. Beachten Sie, dass der Verfall gespeicherter Werte in manchen Rechtsordnungen verbraucherrechtlichen Beschränkungen unterliegt
+ redemption_conversion_amount: '... sind diesen Betrag wert (kleinste Währungseinheiten)'
+ redemption_conversion_help: 'Der Einlösungskurs: so viele Punkte...'
+ redemption_conversion_points: Punkteeinheit
+ retroactive_guest_points: Rückwirkende Gastpunkte
+ retroactive_guest_points_help: Punkte für Gastbestellungen vor der Registrierung vergeben, wenn sich der Gast registriert (keine Nachbuchung für frühere Registrierungen)
+ rounding: Rundung
+ show_earnable_in_cart: Punktehinweis im Warenkorb anzeigen
+ show_earnable_on_product: Punktehinweis auf Produktseiten anzeigen
+ tier_downgrade_grace_days: Karenzzeit bei Stufenabstieg (Tage)
+ tier_downgrade_grace_days_help: Wie lange ein Konto seine Stufe behält, nachdem es unter den Schwellenwert gefallen ist; 0 stuft bei der nächsten nächtlichen Auswertung herab
+ tier_evaluation_window: Auswertungszeitraum für Stufen
+ tier_window_calendar_year: Kalenderjahr
+ tier_window_lifetime: Gesamte Laufzeit
+ tier_window_rolling_12_months: Rollierende 12 Monate
+ rounding_ceil: Aufrunden
+ rounding_floor: Abrunden
+ rounding_round: Kaufmännisch runden
+ promotion_rule:
+ customer_loyalty_tier: Treuestufe des Kunden ist mindestens
+ minimum_tier: Mindeststufe
+ tier:
+ benefits: Vorteile
+ benefits_description: Beschreibung der Vorteile
+ color: Badge-Farbe
+ earning_multiplier: Sammelmultiplikator
+ earning_multiplier_help: Wird auf jeden Punktegewinn angewendet, solange das Konto in dieser Stufe ist (1 = keine Änderung)
+ position_help: Höhere Positionen sind höhere Stufen; die höchste Position ist die Spitzenstufe
+ qualification_basis: Qualifikationsgrundlage
+ threshold: Schwellenwert
+ threshold_help: Die Einheit hängt von der Grundlage ab (Punkte, kleinste Währungseinheiten oder Bestellungen)
+ tier_basis:
+ amount_spent: Ausgegebener Betrag
+ orders_count: Aufgegebene Bestellungen
+ points_earned: Gesammelte Punkte
+ unit:
+ currency: kleinste Währungseinheiten
+ orders: Bestellungen
+ points: Punkte
+ trigger:
+ customer_birthday: Geburtstag des Kunden
+ customer_registered: Kunde registriert
+ order_eligible: Bestellung (eingebaut)
+ product_review_approved: Produktbewertung freigegeben
+ ui:
+ account: Treuekonto
+ account_inactive: Ihr Treuekonto ist derzeit inaktiv — Ihr Saldo bleibt erhalten, aber Sammeln und Einlösen sind pausiert.
+ accounts: Treuekonten
+ accounts_card_description: Konten durchsuchen, Ledger einsehen, Punkte anpassen
+ adjust: Anpassen
+ anonymized: Anonymisiert
+ balance: Ihr Punktestand
+ balance_label: Saldo
+ change_on_cart: Im Warenkorb ändern
+ consumptions: Verbraucht durch
+ dashboard_subtitle: Punkte, Sammelregeln und Programmeinstellungen
+ derived_balance: Ledger-Summe
+ dry_run: Testlauf
+ dry_run_results: Testlauf-Ergebnisse
+ dry_run_results_card_description: Was Testlauf-Regeln vergeben hätten
+ earn_hint_cart: 'Diese Bestellung bringt ~%points% Punkte.'
+ earn_hint_product: 'Beim Kauf dieses Produkts sammeln Sie %points% Punkte.'
+ earning_rate_hint: 'Die Sammelrate wird durch Sammelregeln definiert: Eine Basisregel für die gesamte Bestellung mit dem Typ "pro Betrag" ist die Standardrate des Shops.'
+ earning_rules: Sammelregeln
+ earning_rules_card_description: Trigger, Bedingungen und Beträge
+ edit_earning_rule: Sammelregel bearbeiten
+ edit_program: Programm bearbeiten
+ new_earning_rule: Neue Sammelregel
+ description: Beschreibung
+ expired: Verfallen
+ expires: 'verfällt am %date%'
+ expires_at: Verfällt am
+ expiring_soon: '%points% Punkte verfallen vor dem %date%.'
+ history: Buchungshistorie
+ history_description:
+ clawback: 'Punkte zurückgefordert für eine stornierte oder erstattete Bestellung'
+ earn_action: 'Punkte gesammelt'
+ earn_order: 'Gesammelt mit Bestellung %order%'
+ earn_referral: 'Empfehlungsprämie'
+ expire: 'Punkte verfallen'
+ manual_credit: 'Anpassung: %reason%'
+ manual_debit: 'Anpassung: %reason%'
+ redeem: 'Eingelöst bei Bestellung %order%'
+ redeem_rollback: 'Punkte aus einer stornierten Bestellung wiederhergestellt'
+ history_empty: Noch keine Buchungen — Punkte, die Sie sammeln und einlösen, erscheinen hier.
+ my_loyalty: Mein Treueprogramm
+ running_balance: Saldo
+ inspect: Einsehen
+ invariants_hold: Alle Ledger-Invarianten sind erfüllt.
+ invariants_violated: Ledger-Invarianten sind verletzt — untersuchen Sie dies, bevor Sie manuelle Korrekturen vornehmen.
+ ledger: Ledger
+ lifetime_earned: Insgesamt gesammelt
+ lot: Lot
+ lots: Lots (per Replay abgeleitet)
+ loyalty: Treueprogramm
+ manual_adjustment: Manuelle Anpassung
+ manual_reason:
+ correction: Korrektur
+ goodwill: Kulanz
+ other: Sonstiges
+ promotion: Aktion
+ never: nie
+ note: Notiz
+ points: Punkte
+ points_amount: '%points% Punkte'
+ points_applied: '%points% Punkte angewendet'
+ points_redemption: Punkteeinlösung
+ program: Programm
+ program_card_description: Umrechnung, Verfall und Richtlinien pro Kanal
+ reason: Grund
+ refer_a_friend: Freunde werben
+ referee: Geworbener
+ referral_explainer: Teilen Sie Ihren Link — wenn ein Freund seine erste Bestellung aufgibt, sammeln Sie beide Punkte.
+ referral_override: Trotzdem genehmigen
+ referral_stats: 'Sie haben %count% belohnte Empfehlung(en), die %points% Punkte eingebracht haben.'
+ referral_status:
+ expired: Abgelaufen
+ pending: Ausstehend
+ qualified: Qualifiziert
+ rejected: Abgelehnt
+ rewarded: Belohnt
+ referrals: Empfehlungen
+ referrals_card_description: Empfehlungsstatus und Übersteuerung von Betrugsprüfungen
+ referrer: Werber
+ redemption_clamped: 'Ihre Anforderung von %requested% Punkten wird auf diese Bestellung nur teilweise angewendet; sie wächst automatisch wieder an, sobald die Bestellung es zulässt.'
+ remaining: Verbleibend
+ remove_redemption: Entfernen
+ rule: Regel
+ rule_tester: Regel-Tester
+ rule_tester_card_description: Regeln gegen eine aktuelle Bestellung auswerten, optional zu einem anderen Datum
+ scope: Geltungsbereich
+ share: Teilen
+ copied: Kopiert!
+ stat_accounts: Konten
+ stat_active_accounts_90_days: Aktive Konten (90 Tage)
+ stat_liability: Ausstehende Punkteverbindlichkeit
+ stat_redemption_rate_90_days: Einlösequote (90 Tage)
+ stat_earned_30_days: Gesammelte Punkte (30 Tage)
+ stat_redeemed_30_days: Eingelöste Punkte (30 Tage)
+ tester_applied: Angewendet
+ tester_claimed_basis: Beanspruchte Basis
+ tester_claimed_by: Beansprucht durch
+ tester_evaluate: Auswerten
+ tester_evaluate_at: Auswerten, als wäre es
+ tester_evaluate_at_help: Geplante Regeln vor Öffnung ihres Zeitfensters in der Vorschau prüfen
+ tester_failed_conditions: Nicht erfüllte Bedingungen
+ tester_final_award: 'Endgültige Vergabe: %points% Punkte'
+ tester_invalid_date: Das Auswertungsdatum ist ungültig.
+ tester_item_claims: Ansprüche pro Position
+ tester_matched: Zutreffend
+ tester_order_has_no_customer: Die Bestellung hat keinen Kunden, daher können keine Regeln ausgewertet werden.
+ tester_order_not_found: Es wurde keine Bestellung mit dieser Nummer gefunden.
+ tester_order_number: Bestellnummer
+ tester_rules: Regelauswertungen
+ tier: Stufe
+ tier_progress: '%metric% / %threshold% bis %tier%'
+ tier_top: Sie haben die höchste Stufe erreicht — vielen Dank, dass Sie zu unseren allerbesten Kunden gehören!
+ tiers: Stufen
+ tiers_card_description: Stufen, Schwellenwerte, Multiplikatoren und Vorteile
+ trigger: Trigger
+ use_max: Maximum verwenden
+ your_code: Ihr Code
+ use_your_points: Punkte einlösen
diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml
new file mode 100644
index 0000000..21f73c9
--- /dev/null
+++ b/src/Resources/translations/messages.en.yaml
@@ -0,0 +1,232 @@
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Add condition
+ amount_configuration: Amount configuration
+ amount_configuration_help: 'JSON, e.g. {"points": 1, "per_amount": 100} for per-amount, {"points": 50} for fixed, {"factor": 2} for multiplier, {"expression": "floor(basis / 100)"} for expression'
+ amount_expression: Amount expression
+ amount_expression_help: Used when the amount type is "Expression"; the "basis" variable holds the rule's claimed basis in minor units
+ amount_requires_order_trigger: The per-amount and multiplier types need an order basis and are only available for the built-in order trigger.
+ amount_type: Amount type
+ condition_configuration: Condition configuration
+ condition_configuration_help: 'JSON, e.g. {"amount": 5000} or {"taxons": ["t_shirts"]}'
+ condition_expression: Condition expression
+ condition_expression_help: Used when the condition type is "Expression"; the condition passes when the expression is truthy
+ condition_type: Condition type
+ expression_required: Expression mode needs an expression.
+ multiplier_is_order_scoped: Multiplier rules are order-scoped only; use expression mode for item-level logic.
+ scope_requires_order_trigger: Taxon and product scopes need order items and are only available for the built-in order trigger.
+ conditions: Conditions
+ conditions_match: Conditions match
+ conditions_match_all: All conditions must pass
+ conditions_match_any: At least one condition must pass
+ dry_run: Dry run
+ dry_run_help: Evaluate against live traffic and log what would be awarded to the audit list, without writing any points
+ ends_at: Ends at
+ scope: Scope
+ scope_configuration: Scope configuration
+ scope_configuration_help: 'JSON, e.g. {"taxons": ["t_shirts"]} or {"products": ["MUG"]}; leave empty for order scope'
+ scope_help: Product- or taxon-scoped rules claim their matching items exclusively; order-scoped rules earn on the remainder
+ scope_order: Whole order
+ scope_product: Specific products
+ scope_taxon: Specific taxons
+ stackable: Stackable
+ stackable_help: Stackable rules competing for the same basis all apply and sum; a non-stackable rule applies alone (highest priority wins)
+ starts_at: Starts at
+ trigger: Trigger
+ window_timezone_help: Entered and evaluated in the application's configured timezone
+ earning_rule.amount:
+ expression: Expression
+ fixed: Fixed points
+ multiplier: Multiplier
+ per_amount: Points per amount
+ earning_rule.condition:
+ cart_contains_taxon: Cart contains taxon
+ customer_group: Customer group
+ date_window: Date window
+ day_of_week: Day of week
+ expression: Expression
+ nth_order: Nth order
+ order_total_at_least: Order total at least
+ invalid_json: This value is not valid JSON.
+ program:
+ award_moment_order_fulfilled: When the order is fulfilled
+ award_moment_payment_paid: When the payment is fully paid
+ award_order_points_at: Award order points at
+ award_order_points_at_help: The single order-lifecycle moment points are awarded — one per program, never per rule
+ base_currency_help: Amounts are in minor units of the channel's base currency
+ clawback_policy: Clawback policy
+ clawback_policy_allow_negative: Allow negative balance (recovered from future earnings)
+ clawback_policy_clamp_to_zero: Clamp the balance to zero
+ earning_basis: Earning basis
+ earning_basis_help: What counts toward earning; the earning rate itself is defined by earning rules
+ earning_basis_items_total: Items total (excl. shipping)
+ earning_basis_order_total: Order total (incl. shipping)
+ include_taxes: Include taxes in the basis
+ max_redeem_percent_of_order: Max. redeemable percent of order
+ max_redeem_percent_of_order_help: The share of the order items total coverable by points (100 allows fully free orders)
+ min_redeem_points: Minimum points per redemption
+ points_expiry_days: Points expire after (days)
+ points_expiry_days_help: Leave empty so points never expire. Note that expiring stored value is subject to consumer-law constraints in some jurisdictions
+ redemption_conversion_amount: '... are worth this amount (minor units)'
+ redemption_conversion_help: 'The redemption conversion: this many points...'
+ redemption_conversion_points: Points unit
+ retroactive_guest_points: Retroactive guest points
+ retroactive_guest_points_help: Award points for pre-registration guest orders when the guest registers (no backfill for earlier registrations)
+ rounding: Rounding
+ show_earnable_in_cart: Show the earn hint in the cart
+ show_earnable_on_product: Show the earn hint on product pages
+ tier_downgrade_grace_days: Tier downgrade grace (days)
+ tier_downgrade_grace_days_help: How long an account keeps its tier after falling below the threshold; 0 downgrades at the next nightly evaluation
+ tier_evaluation_window: Tier evaluation window
+ tier_window_calendar_year: Calendar year
+ tier_window_lifetime: Lifetime
+ tier_window_rolling_12_months: Rolling 12 months
+ rounding_ceil: Ceiling
+ rounding_floor: Floor
+ rounding_round: Round
+ promotion_rule:
+ customer_loyalty_tier: Customer's loyalty tier is at least
+ minimum_tier: Minimum tier
+ tier:
+ benefits: Benefits
+ benefits_description: Benefits description
+ color: Badge color
+ earning_multiplier: Earning multiplier
+ earning_multiplier_help: Applied to every points earn while the account is in this tier (1 = no change)
+ position_help: Higher positions are higher tiers; the highest position is the top tier
+ qualification_basis: Qualification basis
+ threshold: Threshold
+ threshold_help: The unit depends on the basis (points, minor currency units, or orders)
+ tier_basis:
+ amount_spent: Amount spent
+ orders_count: Orders placed
+ points_earned: Points earned
+ unit:
+ currency: minor currency units
+ orders: orders
+ points: points
+ trigger:
+ customer_birthday: Customer birthday
+ customer_registered: Customer registered
+ order_eligible: Order (built-in)
+ product_review_approved: Product review approved
+ ui:
+ account: Loyalty account
+ account_inactive: Your loyalty account is currently inactive — your balance is preserved, but earning and redeeming are paused.
+ accounts: Loyalty accounts
+ accounts_card_description: Browse accounts, inspect ledgers, adjust points
+ adjust: Adjust
+ anonymized: Anonymized
+ balance: Your points balance
+ balance_label: Balance
+ change_on_cart: Change on cart
+ consumptions: Consumed by
+ dashboard_subtitle: Points, earning rules, and program settings
+ derived_balance: Ledger sum
+ dry_run: Dry run
+ dry_run_results: Dry-run results
+ dry_run_results_card_description: What dry-run rules would have awarded
+ earn_hint_cart: 'This order earns ~%points% points.'
+ earn_hint_product: 'You will earn %points% points buying this product.'
+ earning_rate_hint: 'The earning rate is defined by earning rules: a base order-scoped rule with the per-amount type is the shop''s default rate.'
+ earning_rules: Earning rules
+ earning_rules_card_description: Triggers, conditions, and amounts
+ edit_earning_rule: Edit earning rule
+ edit_program: Edit program
+ new_earning_rule: New earning rule
+ description: Description
+ expired: Expired
+ expires: 'expires %date%'
+ expires_at: Expires at
+ expiring_soon: '%points% points expire before %date%.'
+ history: Transaction history
+ history_description:
+ clawback: 'Points reclaimed for a cancelled or refunded order'
+ earn_action: 'Points earned'
+ earn_order: 'Earned on order %order%'
+ earn_referral: 'Referral reward'
+ expire: 'Points expired'
+ manual_credit: 'Adjustment: %reason%'
+ manual_debit: 'Adjustment: %reason%'
+ redeem: 'Redeemed on order %order%'
+ redeem_rollback: 'Points restored from a cancelled order'
+ history_empty: No transactions yet — points you earn and spend will show up here.
+ my_loyalty: My loyalty
+ running_balance: Balance
+ inspect: Inspect
+ invariants_hold: All ledger invariants hold.
+ invariants_violated: Ledger invariants are violated — investigate before making manual corrections.
+ ledger: Ledger
+ lifetime_earned: Lifetime earned
+ lot: Lot
+ lots: Lots (replay-derived)
+ loyalty: Loyalty
+ manual_adjustment: Manual adjustment
+ manual_reason:
+ correction: Correction
+ goodwill: Goodwill
+ other: Other
+ promotion: Promotion
+ never: never
+ note: Note
+ points: Points
+ points_amount: '%points% points'
+ points_applied: '%points% points applied'
+ points_redemption: Points redemption
+ program: Program
+ program_card_description: Per-channel conversion, expiry, and policies
+ reason: Reason
+ refer_a_friend: Refer a friend
+ referee: Referee
+ referral_explainer: Share your link — when a friend places their first order, you both earn points.
+ referral_override: Approve anyway
+ referral_stats: 'You have %count% rewarded referral(s), earning %points% points.'
+ referral_status:
+ expired: Expired
+ pending: Pending
+ qualified: Qualified
+ rejected: Rejected
+ rewarded: Rewarded
+ referrals: Referrals
+ referrals_card_description: Referral statuses and fraud overrides
+ referrer: Referrer
+ redemption_clamped: 'Your request of %requested% points is only partially applied to this order; it grows back automatically when the order allows it.'
+ remaining: Remaining
+ remove_redemption: Remove
+ rule: Rule
+ rule_tester: Rule tester
+ rule_tester_card_description: Evaluate rules against a recent order, optionally at a different date
+ scope: Scope
+ share: Share
+ copied: Copied!
+ stat_accounts: Accounts
+ stat_active_accounts_90_days: Active accounts (90 days)
+ stat_liability: Outstanding points liability
+ stat_redemption_rate_90_days: Redemption rate (90 days)
+ stat_earned_30_days: Points earned (30 days)
+ stat_redeemed_30_days: Points redeemed (30 days)
+ tester_applied: Applied
+ tester_claimed_basis: Claimed basis
+ tester_claimed_by: Claimed by
+ tester_evaluate: Evaluate
+ tester_evaluate_at: Evaluate as if it were
+ tester_evaluate_at_help: Preview scheduled rules before their window opens
+ tester_failed_conditions: Failed conditions
+ tester_final_award: 'Final award: %points% points'
+ tester_invalid_date: The evaluation date is invalid.
+ tester_item_claims: Per-item claims
+ tester_matched: Matched
+ tester_order_has_no_customer: The order has no customer, so no rules can be evaluated.
+ tester_order_not_found: No order with that number was found.
+ tester_order_number: Order number
+ tester_rules: Rule evaluations
+ tier: Tier
+ tier_progress: '%metric% / %threshold% to %tier%'
+ tier_top: You have reached the top tier — thank you for being one of our very best customers!
+ tiers: Tiers
+ tiers_card_description: Levels, thresholds, multipliers, and benefits
+ trigger: Trigger
+ use_max: Use max
+ your_code: Your code
+ use_your_points: Use your points
diff --git a/src/Resources/translations/messages.es.yaml b/src/Resources/translations/messages.es.yaml
new file mode 100644
index 0000000..3cbc2ca
--- /dev/null
+++ b/src/Resources/translations/messages.es.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Añadir condición
+ amount_configuration: Configuración del importe
+ amount_configuration_help: 'JSON, p. ej. {"points": 1, "per_amount": 100} para por importe, {"points": 50} para fijo, {"factor": 2} para multiplicador, {"expression": "floor(basis / 100)"} para expresión'
+ amount_expression: Expresión del importe
+ amount_expression_help: Se usa cuando el tipo de importe es «Expresión»; la variable «basis» contiene la base reclamada por la regla en unidades menores
+ amount_requires_order_trigger: Los tipos por importe y multiplicador necesitan una base de pedido y solo están disponibles para el disparador de pedido integrado.
+ amount_type: Tipo de importe
+ condition_configuration: Configuración de la condición
+ condition_configuration_help: 'JSON, p. ej. {"amount": 5000} o {"taxons": ["t_shirts"]}'
+ condition_expression: Expresión de la condición
+ condition_expression_help: Se usa cuando el tipo de condición es «Expresión»; la condición se cumple cuando la expresión es verdadera
+ condition_type: Tipo de condición
+ expression_required: El modo expresión necesita una expresión.
+ multiplier_is_order_scoped: Las reglas de multiplicador se aplican solo a nivel de pedido; usa el modo expresión para lógica a nivel de artículo.
+ scope_requires_order_trigger: Los ámbitos de taxón y producto necesitan artículos de pedido y solo están disponibles para el disparador de pedido integrado.
+ conditions: Condiciones
+ conditions_match: Coincidencia de condiciones
+ conditions_match_all: Todas las condiciones deben cumplirse
+ conditions_match_any: Al menos una condición debe cumplirse
+ dry_run: Simulación
+ dry_run_help: Evaluar sobre el tráfico real y registrar lo que se habría otorgado en la lista de auditoría, sin acreditar ningún punto
+ ends_at: Termina el
+ scope: Ámbito
+ scope_configuration: Configuración del ámbito
+ scope_configuration_help: 'JSON, p. ej. {"taxons": ["t_shirts"]} o {"products": ["MUG"]}; dejar vacío para el ámbito de pedido'
+ scope_help: Las reglas limitadas a producto o taxón reclaman en exclusiva sus artículos coincidentes; las reglas a nivel de pedido ganan sobre el resto
+ scope_order: Pedido completo
+ scope_product: Productos específicos
+ scope_taxon: Taxones específicos
+ stackable: Acumulable
+ stackable_help: Las reglas acumulables que compiten por la misma base se aplican todas y se suman; una regla no acumulable se aplica sola (gana la de mayor prioridad)
+ starts_at: Comienza el
+ trigger: Disparador
+ window_timezone_help: Se introduce y evalúa en la zona horaria configurada de la aplicación
+ earning_rule.amount:
+ expression: Expresión
+ fixed: Puntos fijos
+ multiplier: Multiplicador
+ per_amount: Puntos por importe
+ earning_rule.condition:
+ cart_contains_taxon: El carrito contiene un taxón
+ customer_group: Grupo de clientes
+ date_window: Periodo de fechas
+ day_of_week: Día de la semana
+ expression: Expresión
+ nth_order: Enésimo pedido
+ order_total_at_least: Total del pedido como mínimo
+ invalid_json: Este valor no es un JSON válido.
+ program:
+ award_moment_order_fulfilled: Cuando el pedido se completa
+ award_moment_payment_paid: Cuando el pago está totalmente abonado
+ award_order_points_at: Otorgar los puntos del pedido en
+ award_order_points_at_help: El único momento del ciclo de vida del pedido en que se otorgan los puntos — uno por programa, nunca por regla
+ base_currency_help: Los importes están en unidades menores de la moneda base del canal
+ clawback_policy: Política de recuperación
+ clawback_policy_allow_negative: Permitir saldo negativo (se recupera de ganancias futuras)
+ clawback_policy_clamp_to_zero: Limitar el saldo a cero
+ earning_basis: Base de acumulación
+ earning_basis_help: Lo que cuenta para acumular puntos; la tasa de acumulación en sí se define mediante reglas de acumulación
+ earning_basis_items_total: Total de artículos (sin envío)
+ earning_basis_order_total: Total del pedido (envío incluido)
+ include_taxes: Incluir impuestos en la base
+ max_redeem_percent_of_order: Porcentaje máx. canjeable del pedido
+ max_redeem_percent_of_order_help: La parte del total de artículos del pedido que puede cubrirse con puntos (100 permite pedidos totalmente gratuitos)
+ min_redeem_points: Puntos mínimos por canje
+ points_expiry_days: Los puntos caducan después de (días)
+ points_expiry_days_help: Dejar vacío para que los puntos no caduquen nunca. Ten en cuenta que la caducidad del saldo acumulado está sujeta a la normativa de consumo en algunas jurisdicciones
+ redemption_conversion_amount: '... valen este importe (unidades menores)'
+ redemption_conversion_help: 'La conversión de canje: esta cantidad de puntos...'
+ redemption_conversion_points: Unidad de puntos
+ retroactive_guest_points: Puntos retroactivos de invitado
+ retroactive_guest_points_help: Otorgar puntos por los pedidos realizados como invitado antes del registro cuando el invitado se registra (sin efecto retroactivo para registros anteriores)
+ rounding: Redondeo
+ show_earnable_in_cart: Mostrar el aviso de puntos en el carrito
+ show_earnable_on_product: Mostrar el aviso de puntos en las páginas de producto
+ tier_downgrade_grace_days: Gracia antes de bajar de nivel (días)
+ tier_downgrade_grace_days_help: Cuánto tiempo conserva una cuenta su nivel tras caer por debajo del umbral; 0 baja de nivel en la siguiente evaluación nocturna
+ tier_evaluation_window: Ventana de evaluación de niveles
+ tier_window_calendar_year: Año natural
+ tier_window_lifetime: De por vida
+ tier_window_rolling_12_months: 12 meses móviles
+ rounding_ceil: Hacia arriba
+ rounding_floor: Hacia abajo
+ rounding_round: Al más cercano
+ promotion_rule:
+ customer_loyalty_tier: El nivel de fidelidad del cliente es al menos
+ minimum_tier: Nivel mínimo
+ tier:
+ benefits: Beneficios
+ benefits_description: Descripción de los beneficios
+ color: Color de la insignia
+ earning_multiplier: Multiplicador de acumulación
+ earning_multiplier_help: Se aplica a cada acumulación de puntos mientras la cuenta está en este nivel (1 = sin cambios)
+ position_help: Las posiciones más altas son niveles más altos; la posición más alta es el nivel superior
+ qualification_basis: Base de cualificación
+ threshold: Umbral
+ threshold_help: La unidad depende de la base (puntos, unidades menores de moneda o pedidos)
+ tier_basis:
+ amount_spent: Importe gastado
+ orders_count: Pedidos realizados
+ points_earned: Puntos ganados
+ unit:
+ currency: unidades menores de moneda
+ orders: pedidos
+ points: puntos
+ trigger:
+ customer_birthday: Cumpleaños del cliente
+ customer_registered: Cliente registrado
+ order_eligible: Pedido (integrado)
+ product_review_approved: Reseña de producto aprobada
+ ui:
+ account: Cuenta de fidelidad
+ account_inactive: Tu cuenta de fidelidad está actualmente inactiva — tu saldo se conserva, pero la acumulación y el canje están en pausa.
+ accounts: Cuentas de fidelidad
+ accounts_card_description: Explorar cuentas, inspeccionar libros mayores, ajustar puntos
+ adjust: Ajustar
+ anonymized: Anonimizado
+ balance: Tu saldo de puntos
+ balance_label: Saldo
+ change_on_cart: Cambiar en el carrito
+ consumptions: Consumido por
+ dashboard_subtitle: Puntos, reglas de acumulación y configuración del programa
+ derived_balance: Suma del libro mayor
+ dry_run: Simulación
+ dry_run_results: Resultados de simulación
+ dry_run_results_card_description: Lo que las reglas en simulación habrían otorgado
+ earn_hint_cart: 'Este pedido acumula ~%points% puntos.'
+ earn_hint_product: 'Ganarás %points% puntos al comprar este producto.'
+ earning_rate_hint: 'La tasa de acumulación se define mediante reglas de acumulación: una regla base a nivel de pedido con el tipo «puntos por importe» es la tasa predeterminada de la tienda.'
+ earning_rules: Reglas de acumulación
+ earning_rules_card_description: Disparadores, condiciones e importes
+ edit_earning_rule: Editar regla de acumulación
+ edit_program: Editar programa
+ new_earning_rule: Nueva regla de acumulación
+ description: Descripción
+ expired: Caducado
+ expires: 'caduca el %date%'
+ expires_at: Caduca el
+ expiring_soon: '%points% puntos caducan antes del %date%.'
+ history: Historial de transacciones
+ history_description:
+ clawback: 'Puntos recuperados por un pedido cancelado o reembolsado'
+ earn_action: 'Puntos ganados'
+ earn_order: 'Ganados en el pedido %order%'
+ earn_referral: 'Recompensa por recomendación'
+ expire: 'Puntos caducados'
+ manual_credit: 'Ajuste: %reason%'
+ manual_debit: 'Ajuste: %reason%'
+ redeem: 'Canjeados en el pedido %order%'
+ redeem_rollback: 'Puntos restaurados de un pedido cancelado'
+ history_empty: Aún no hay transacciones — los puntos que ganes y gastes aparecerán aquí.
+ my_loyalty: Mi fidelidad
+ running_balance: Saldo
+ inspect: Inspeccionar
+ invariants_hold: Todos los invariantes del libro mayor se cumplen.
+ invariants_violated: Los invariantes del libro mayor están violados — investiga antes de hacer correcciones manuales.
+ ledger: Libro mayor
+ lifetime_earned: Ganado en total
+ lot: Lote
+ lots: Lotes (derivados por reproducción)
+ loyalty: Fidelidad
+ manual_adjustment: Ajuste manual
+ manual_reason:
+ correction: Corrección
+ goodwill: Cortesía
+ other: Otro
+ promotion: Promoción
+ never: nunca
+ note: Nota
+ points: Puntos
+ points_amount: '%points% puntos'
+ points_applied: '%points% puntos aplicados'
+ points_redemption: Canje de puntos
+ program: Programa
+ program_card_description: Conversión, caducidad y políticas por canal
+ reason: Motivo
+ refer_a_friend: Recomienda a un amigo
+ referee: Recomendado
+ referral_explainer: Comparte tu enlace — cuando un amigo haga su primer pedido, los dos ganáis puntos.
+ referral_override: Aprobar de todos modos
+ referral_stats: 'Tienes %count% recomendación(es) recompensada(s), que han generado %points% puntos.'
+ referral_status:
+ expired: Caducada
+ pending: Pendiente
+ qualified: Cualificada
+ rejected: Rechazada
+ rewarded: Recompensada
+ referrals: Recomendaciones
+ referrals_card_description: Estados de las recomendaciones y anulación de rechazos por fraude
+ referrer: Recomendante
+ redemption_clamped: 'Tu solicitud de %requested% puntos solo se aplica parcialmente a este pedido; volverá a aumentar automáticamente cuando el pedido lo permita.'
+ remaining: Restante
+ remove_redemption: Quitar
+ rule: Regla
+ rule_tester: Probador de reglas
+ rule_tester_card_description: Evaluar reglas sobre un pedido reciente, opcionalmente en otra fecha
+ scope: Ámbito
+ share: Compartir
+ copied: ¡Copiado!
+ stat_accounts: Cuentas
+ stat_active_accounts_90_days: Cuentas activas (90 días)
+ stat_liability: Pasivo de puntos pendiente
+ stat_redemption_rate_90_days: Tasa de canje (90 días)
+ stat_earned_30_days: Puntos ganados (30 días)
+ stat_redeemed_30_days: Puntos canjeados (30 días)
+ tester_applied: Aplicado
+ tester_claimed_basis: Base reclamada
+ tester_claimed_by: Reclamado por
+ tester_evaluate: Evaluar
+ tester_evaluate_at: Evaluar como si fuera
+ tester_evaluate_at_help: Previsualizar las reglas programadas antes de que se abra su periodo
+ tester_failed_conditions: Condiciones no cumplidas
+ tester_final_award: 'Otorgamiento final: %points% puntos'
+ tester_invalid_date: La fecha de evaluación no es válida.
+ tester_item_claims: Reclamaciones por artículo
+ tester_matched: Coincide
+ tester_order_has_no_customer: El pedido no tiene cliente, por lo que no se puede evaluar ninguna regla.
+ tester_order_not_found: No se encontró ningún pedido con ese número.
+ tester_order_number: Número de pedido
+ tester_rules: Evaluaciones de reglas
+ tier: Nivel
+ tier_progress: '%metric% / %threshold% para %tier%'
+ tier_top: Has alcanzado el nivel más alto — ¡gracias por ser uno de nuestros mejores clientes!
+ tiers: Niveles
+ tiers_card_description: Niveles, umbrales, multiplicadores y beneficios
+ trigger: Disparador
+ use_max: Usar el máximo
+ your_code: Tu código
+ use_your_points: Usa tus puntos
diff --git a/src/Resources/translations/messages.fi.yaml b/src/Resources/translations/messages.fi.yaml
new file mode 100644
index 0000000..3110ffc
--- /dev/null
+++ b/src/Resources/translations/messages.fi.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Lisää ehto
+ amount_configuration: Määrän asetukset
+ amount_configuration_help: 'JSON, esim. {"points": 1, "per_amount": 100} summaperusteiselle, {"points": 50} kiinteälle, {"factor": 2} kertoimelle, {"expression": "floor(basis / 100)"} lausekkeelle'
+ amount_expression: Määrälauseke
+ amount_expression_help: Käytetään, kun määrätyyppi on "Lauseke"; muuttuja "basis" sisältää säännön varaaman perusteen pienimmissä yksiköissä
+ amount_requires_order_trigger: Summaperusteinen ja kerroin-tyypit tarvitsevat tilausperusteen ja ovat käytettävissä vain sisäänrakennetulle tilauslaukaisimelle.
+ amount_type: Määrätyyppi
+ condition_configuration: Ehdon asetukset
+ condition_configuration_help: 'JSON, esim. {"amount": 5000} tai {"taxons": ["t_shirts"]}'
+ condition_expression: Ehtolauseke
+ condition_expression_help: Käytetään, kun ehtotyyppi on "Lauseke"; ehto täyttyy, kun lauseke on tosi
+ condition_type: Ehtotyyppi
+ expression_required: Lauseketila vaatii lausekkeen.
+ multiplier_is_order_scoped: Kerroinsäännöt koskevat vain koko tilausta; käytä lauseketilaa rivikohtaiseen logiikkaan.
+ scope_requires_order_trigger: Taksoni- ja tuoterajaukset tarvitsevat tilausrivejä ja ovat käytettävissä vain sisäänrakennetulle tilauslaukaisimelle.
+ conditions: Ehdot
+ conditions_match: Ehtojen täsmäys
+ conditions_match_all: Kaikkien ehtojen on täytyttävä
+ conditions_match_any: Vähintään yhden ehdon on täytyttävä
+ dry_run: Koeajo
+ dry_run_help: Arvioi todellista liikennettä vastaan ja kirjaa tarkastuslistalle, mitä olisi myönnetty, kirjoittamatta yhtään pistettä
+ ends_at: Päättyy
+ scope: Rajaus
+ scope_configuration: Rajauksen asetukset
+ scope_configuration_help: 'JSON, esim. {"taxons": ["t_shirts"]} tai {"products": ["MUG"]}; jätä tyhjäksi koko tilauksen rajaukselle'
+ scope_help: Tuote- tai taksonirajatut säännöt varaavat täsmäävät rivinsä yksinoikeudella; koko tilaukseen rajatut säännöt kerryttävät pisteitä jäljelle jäävästä osasta
+ scope_order: Koko tilaus
+ scope_product: Tietyt tuotteet
+ scope_taxon: Tietyt taksonit
+ stackable: Yhdisteltävä
+ stackable_help: Samasta perusteesta kilpailevat yhdisteltävät säännöt pätevät kaikki ja lasketaan yhteen; ei-yhdisteltävä sääntö pätee yksin (korkein prioriteetti voittaa)
+ starts_at: Alkaa
+ trigger: Laukaisin
+ window_timezone_help: Syötetään ja arvioidaan sovelluksen määritetyllä aikavyöhykkeellä
+ earning_rule.amount:
+ expression: Lauseke
+ fixed: Kiinteät pisteet
+ multiplier: Kerroin
+ per_amount: Pisteitä summaa kohden
+ earning_rule.condition:
+ cart_contains_taxon: Ostoskori sisältää taksonin
+ customer_group: Asiakasryhmä
+ date_window: Aikaväli
+ day_of_week: Viikonpäivä
+ expression: Lauseke
+ nth_order: Monesko tilaus
+ order_total_at_least: Tilauksen summa vähintään
+ invalid_json: Tämä arvo ei ole kelvollista JSONia.
+ program:
+ award_moment_order_fulfilled: Kun tilaus on toimitettu
+ award_moment_payment_paid: Kun maksu on kokonaan maksettu
+ award_order_points_at: Tilauspisteiden myöntöhetki
+ award_order_points_at_help: Tilauksen elinkaaren ainoa hetki, jolloin pisteet myönnetään — yksi ohjelmaa kohden, ei koskaan sääntöä kohden
+ base_currency_help: Summat ovat kanavan perusvaluutan pienimmissä yksiköissä
+ clawback_policy: Takaisinperintäkäytäntö
+ clawback_policy_allow_negative: Salli negatiivinen saldo (peritään tulevista kertymistä)
+ clawback_policy_clamp_to_zero: Rajaa saldo nollaan
+ earning_basis: Kertymisperuste
+ earning_basis_help: Mikä lasketaan mukaan kertymään; itse kertymistahti määritellään kertymissäännöillä
+ earning_basis_items_total: Tuotteiden summa (ilman toimitusta)
+ earning_basis_order_total: Tilauksen summa (sis. toimituksen)
+ include_taxes: Sisällytä verot perusteeseen
+ max_redeem_percent_of_order: Tilauksen enimmäislunastusprosentti
+ max_redeem_percent_of_order_help: Osuus tilauksen tuotteiden summasta, joka voidaan kattaa pisteillä (100 sallii täysin ilmaiset tilaukset)
+ min_redeem_points: Pisteiden vähimmäismäärä lunastusta kohden
+ points_expiry_days: Pisteet vanhenevat (päivää)
+ points_expiry_days_help: Jätä tyhjäksi, jolloin pisteet eivät koskaan vanhene. Huomaa, että talletetun arvon vanheneminen voi olla kuluttajansuojalainsäädännön rajoitusten alaista joillakin lainkäyttöalueilla
+ redemption_conversion_amount: '... ovat tämän summan arvoisia (pienimmissä yksiköissä)'
+ redemption_conversion_help: 'Lunastuskurssi: näin monta pistettä...'
+ redemption_conversion_points: Pisteyksikkö
+ retroactive_guest_points: Takautuvat vieraspisteet
+ retroactive_guest_points_help: Myönnä pisteet ennen rekisteröitymistä tehdyistä vierastilauksista, kun vieras rekisteröityy (ei takautuvaa hyvitystä aiemmille rekisteröitymisille)
+ rounding: Pyöristys
+ show_earnable_in_cart: Näytä pistevihje ostoskorissa
+ show_earnable_on_product: Näytä pistevihje tuotesivuilla
+ tier_downgrade_grace_days: Tason pudotuksen lykkäys (päivää)
+ tier_downgrade_grace_days_help: Kuinka kauan tili säilyttää tasonsa pudottuaan kynnyksen alle; 0 pudottaa tason seuraavassa öisessä arvioinnissa
+ tier_evaluation_window: Tasojen arviointijakso
+ tier_window_calendar_year: Kalenterivuosi
+ tier_window_lifetime: Koko asiakkuuden ajalta
+ tier_window_rolling_12_months: Liukuva 12 kuukautta
+ rounding_ceil: Ylöspäin
+ rounding_floor: Alaspäin
+ rounding_round: Lähimpään
+ promotion_rule:
+ customer_loyalty_tier: Asiakkaan kanta-asiakastaso on vähintään
+ minimum_tier: Vähimmäistaso
+ tier:
+ benefits: Edut
+ benefits_description: Etujen kuvaus
+ color: Merkin väri
+ earning_multiplier: Kertymiskerroin
+ earning_multiplier_help: Sovelletaan kaikkeen pisteiden kertymiseen, kun tili on tällä tasolla (1 = ei muutosta)
+ position_help: Suuremmat sijainnit ovat korkeampia tasoja; suurin sijainti on ylin taso
+ qualification_basis: Kelpoisuusperuste
+ threshold: Kynnys
+ threshold_help: Yksikkö riippuu perusteesta (pisteet, valuutan pienimmät yksiköt tai tilaukset)
+ tier_basis:
+ amount_spent: Käytetty rahamäärä
+ orders_count: Tehdyt tilaukset
+ points_earned: Kertyneet pisteet
+ unit:
+ currency: valuutan pienimmät yksiköt
+ orders: tilausta
+ points: pistettä
+ trigger:
+ customer_birthday: Asiakkaan syntymäpäivä
+ customer_registered: Asiakas rekisteröitynyt
+ order_eligible: Tilaus (sisäänrakennettu)
+ product_review_approved: Tuotearvostelu hyväksytty
+ ui:
+ account: Kanta-asiakastili
+ account_inactive: Kanta-asiakastilisi ei ole tällä hetkellä käytössä — saldosi säilyy, mutta pisteiden kerryttäminen ja lunastaminen on keskeytetty.
+ accounts: Kanta-asiakastilit
+ accounts_card_description: Selaa tilejä, tarkastele kirjanpitoa, muokkaa pisteitä
+ adjust: Muokkaa
+ anonymized: Anonymisoitu
+ balance: Pistesaldosi
+ balance_label: Saldo
+ change_on_cart: Muuta ostoskorissa
+ consumptions: Kulutettu
+ dashboard_subtitle: Pisteet, kertymissäännöt ja ohjelman asetukset
+ derived_balance: Kirjanpidon summa
+ dry_run: Koeajo
+ dry_run_results: Koeajon tulokset
+ dry_run_results_card_description: Mitä koeajosäännöt olisivat myöntäneet
+ earn_hint_cart: 'Tästä tilauksesta kertyy ~%points% pistettä.'
+ earn_hint_product: 'Ansaitset %points% pistettä ostamalla tämän tuotteen.'
+ earning_rate_hint: 'Kertymistahti määritellään kertymissäännöillä: koko tilaukseen rajattu perussääntö summaperusteisella tyypillä on kaupan oletustahti.'
+ earning_rules: Kertymissäännöt
+ earning_rules_card_description: Laukaisimet, ehdot ja määrät
+ edit_earning_rule: Muokkaa kertymissääntöä
+ edit_program: Muokkaa ohjelmaa
+ new_earning_rule: Uusi kertymissääntö
+ description: Kuvaus
+ expired: Vanhentunut
+ expires: 'vanhenee %date%'
+ expires_at: Vanhenee
+ expiring_soon: '%points% pistettä vanhenee ennen %date%.'
+ history: Tapahtumahistoria
+ history_description:
+ clawback: 'Pisteet peritty takaisin peruutetusta tai hyvitetystä tilauksesta'
+ earn_action: 'Pisteitä ansaittu'
+ earn_order: 'Ansaittu tilauksesta %order%'
+ earn_referral: 'Suosittelupalkkio'
+ expire: 'Pisteet vanhentuneet'
+ manual_credit: 'Oikaisu: %reason%'
+ manual_debit: 'Oikaisu: %reason%'
+ redeem: 'Lunastettu tilaukseen %order%'
+ redeem_rollback: 'Pisteet palautettu peruutetusta tilauksesta'
+ history_empty: Ei vielä tapahtumia — ansaitsemasi ja käyttämäsi pisteet näkyvät täällä.
+ my_loyalty: Kanta-asiakkuuteni
+ running_balance: Saldo
+ inspect: Tarkastele
+ invariants_hold: Kaikki kirjanpidon invariantit pitävät.
+ invariants_violated: Kirjanpidon invariantteja on rikottu — selvitä asia ennen manuaalisia korjauksia.
+ ledger: Kirjanpito
+ lifetime_earned: Ansaittu yhteensä
+ lot: Erä
+ lots: Erät (johdettu toistamalla)
+ loyalty: Kanta-asiakkuus
+ manual_adjustment: Manuaalinen oikaisu
+ manual_reason:
+ correction: Korjaus
+ goodwill: Hyvitys
+ other: Muu
+ promotion: Kampanja
+ never: ei koskaan
+ note: Huomautus
+ points: Pisteet
+ points_amount: '%points% pistettä'
+ points_applied: '%points% pistettä käytetty'
+ points_redemption: Pisteiden lunastus
+ program: Ohjelma
+ program_card_description: Kanavakohtainen muuntokurssi, vanheneminen ja käytännöt
+ reason: Syy
+ refer_a_friend: Suosittele ystävälle
+ referee: Suositeltu
+ referral_explainer: Jaa linkkisi — kun ystäväsi tekee ensimmäisen tilauksensa, ansaitsette molemmat pisteitä.
+ referral_override: Hyväksy silti
+ referral_stats: 'Sinulla on %count% palkittua suosittelua, joista on kertynyt %points% pistettä.'
+ referral_status:
+ expired: Vanhentunut
+ pending: Odottaa
+ qualified: Kelpuutettu
+ rejected: Hylätty
+ rewarded: Palkittu
+ referrals: Suosittelut
+ referrals_card_description: Suosittelujen tilat ja petostarkistusten ohitukset
+ referrer: Suosittelija
+ redemption_clamped: 'Pyytämästäsi %requested% pisteestä vain osa on käytetty tähän tilaukseen; määrä palautuu automaattisesti, kun tilaus sen sallii.'
+ remaining: Jäljellä
+ remove_redemption: Poista
+ rule: Sääntö
+ rule_tester: Sääntötestaaja
+ rule_tester_card_description: Arvioi sääntöjä äskettäistä tilausta vastaan, halutessasi eri päivämäärällä
+ scope: Rajaus
+ share: Jaa
+ copied: Kopioitu!
+ stat_accounts: Tilit
+ stat_active_accounts_90_days: Aktiiviset tilit (90 päivää)
+ stat_liability: Avoin pistevastuu
+ stat_redemption_rate_90_days: Lunastusaste (90 päivää)
+ stat_earned_30_days: Ansaitut pisteet (30 päivää)
+ stat_redeemed_30_days: Lunastetut pisteet (30 päivää)
+ tester_applied: Sovellettu
+ tester_claimed_basis: Varattu peruste
+ tester_claimed_by: Varannut
+ tester_evaluate: Arvioi
+ tester_evaluate_at: Arvioi ikään kuin olisi
+ tester_evaluate_at_help: Esikatsele ajastettuja sääntöjä ennen niiden aikaikkunan avautumista
+ tester_failed_conditions: Täyttymättömät ehdot
+ tester_final_award: 'Lopullinen myöntö: %points% pistettä'
+ tester_invalid_date: Arviointipäivämäärä on virheellinen.
+ tester_item_claims: Rivikohtaiset varaukset
+ tester_matched: Täsmäsi
+ tester_order_has_no_customer: Tilauksella ei ole asiakasta, joten sääntöjä ei voida arvioida.
+ tester_order_not_found: Tilausta tällä numerolla ei löytynyt.
+ tester_order_number: Tilausnumero
+ tester_rules: Sääntöjen arvioinnit
+ tier: Taso
+ tier_progress: '%metric% / %threshold% tasoon %tier%'
+ tier_top: Olet saavuttanut ylimmän tason — kiitos, että olet yksi parhaista asiakkaistamme!
+ tiers: Tasot
+ tiers_card_description: Tasot, kynnykset, kertoimet ja edut
+ trigger: Laukaisin
+ use_max: Käytä enimmäismäärä
+ your_code: Koodisi
+ use_your_points: Käytä pisteesi
diff --git a/src/Resources/translations/messages.fr.yaml b/src/Resources/translations/messages.fr.yaml
new file mode 100644
index 0000000..4284445
--- /dev/null
+++ b/src/Resources/translations/messages.fr.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Ajouter une condition
+ amount_configuration: Configuration du montant
+ amount_configuration_help: 'JSON, p. ex. {"points": 1, "per_amount": 100} pour un montant proportionnel, {"points": 50} pour un montant fixe, {"factor": 2} pour un multiplicateur, {"expression": "floor(basis / 100)"} pour une expression'
+ amount_expression: Expression du montant
+ amount_expression_help: Utilisée lorsque le type de montant est « Expression » ; la variable « basis » contient la base revendiquée par la règle, en unités mineures
+ amount_requires_order_trigger: Les types proportionnel et multiplicateur nécessitent une base de commande et ne sont disponibles que pour le déclencheur de commande intégré.
+ amount_type: Type de montant
+ condition_configuration: Configuration de la condition
+ condition_configuration_help: 'JSON, p. ex. {"amount": 5000} ou {"taxons": ["t_shirts"]}'
+ condition_expression: Expression de la condition
+ condition_expression_help: 'Utilisée lorsque le type de condition est « Expression » ; la condition est remplie lorsque l''expression est vraie'
+ condition_type: Type de condition
+ expression_required: Le mode expression nécessite une expression.
+ multiplier_is_order_scoped: 'Les règles multiplicatrices s''appliquent uniquement à la commande entière ; utilisez le mode expression pour une logique au niveau des articles.'
+ scope_requires_order_trigger: Les portées taxon et produit nécessitent des articles de commande et ne sont disponibles que pour le déclencheur de commande intégré.
+ conditions: Conditions
+ conditions_match: Correspondance des conditions
+ conditions_match_all: Toutes les conditions doivent être remplies
+ conditions_match_any: Au moins une condition doit être remplie
+ dry_run: Mode simulation
+ dry_run_help: 'Évaluer sur le trafic réel et consigner ce qui aurait été attribué dans la liste d''audit, sans créditer aucun point'
+ ends_at: Se termine le
+ scope: Portée
+ scope_configuration: Configuration de la portée
+ scope_configuration_help: 'JSON, p. ex. {"taxons": ["t_shirts"]} ou {"products": ["MUG"]} ; laisser vide pour la portée commande'
+ scope_help: Les règles limitées à un produit ou à un taxon revendiquent exclusivement leurs articles correspondants ; les règles au niveau de la commande gagnent sur le reste
+ scope_order: Commande entière
+ scope_product: Produits spécifiques
+ scope_taxon: Taxons spécifiques
+ stackable: Cumulable
+ stackable_help: 'Les règles cumulables en concurrence pour la même base s''appliquent toutes et s''additionnent ; une règle non cumulable s''applique seule (la priorité la plus élevée l''emporte)'
+ starts_at: Commence le
+ trigger: Déclencheur
+ window_timezone_help: 'Saisi et évalué dans le fuseau horaire configuré de l''application'
+ earning_rule.amount:
+ expression: Expression
+ fixed: Points fixes
+ multiplier: Multiplicateur
+ per_amount: Points par montant
+ earning_rule.condition:
+ cart_contains_taxon: Le panier contient un taxon
+ customer_group: Groupe de clients
+ date_window: Période de dates
+ day_of_week: Jour de la semaine
+ expression: Expression
+ nth_order: Nième commande
+ order_total_at_least: Total de commande minimum
+ invalid_json: 'Cette valeur n''est pas un JSON valide.'
+ program:
+ award_moment_order_fulfilled: Lorsque la commande est traitée
+ award_moment_payment_paid: Lorsque le paiement est entièrement réglé
+ award_order_points_at: Attribuer les points de commande au moment
+ award_order_points_at_help: Le moment unique du cycle de vie de la commande où les points sont attribués — un par programme, jamais par règle
+ base_currency_help: Les montants sont exprimés en unités mineures de la devise de base du canal
+ clawback_policy: Politique de récupération
+ clawback_policy_allow_negative: Autoriser un solde négatif (récupéré sur les gains futurs)
+ clawback_policy_clamp_to_zero: Limiter le solde à zéro
+ earning_basis: Base de gain
+ earning_basis_help: Ce qui compte pour le gain ; le taux de gain lui-même est défini par les règles de gain
+ earning_basis_items_total: Total des articles (hors livraison)
+ earning_basis_order_total: Total de la commande (livraison incluse)
+ include_taxes: Inclure les taxes dans la base
+ max_redeem_percent_of_order: Pourcentage max. de la commande utilisable
+ max_redeem_percent_of_order_help: La part du total des articles de la commande pouvant être couverte par des points (100 permet des commandes entièrement gratuites)
+ min_redeem_points: Points minimum par utilisation
+ points_expiry_days: Les points expirent après (jours)
+ points_expiry_days_help: 'Laisser vide pour que les points n''expirent jamais. Notez que l''expiration de valeur stockée est soumise à des contraintes de droit de la consommation dans certaines juridictions'
+ redemption_conversion_amount: '... valent ce montant (unités mineures)'
+ redemption_conversion_help: 'La conversion d''utilisation : ce nombre de points...'
+ redemption_conversion_points: Unité de points
+ retroactive_guest_points: Points invités rétroactifs
+ retroactive_guest_points_help: 'Attribuer des points pour les commandes passées en tant qu''invité avant l''inscription, lorsque l''invité s''inscrit (pas de rattrapage pour les inscriptions antérieures)'
+ rounding: Arrondi
+ show_earnable_in_cart: 'Afficher l''indication de gain dans le panier'
+ show_earnable_on_product: 'Afficher l''indication de gain sur les pages produit'
+ tier_downgrade_grace_days: Délai de grâce avant rétrogradation de niveau (jours)
+ tier_downgrade_grace_days_help: Durée pendant laquelle un compte conserve son niveau après être passé sous le seuil ; 0 rétrograde à la prochaine évaluation nocturne
+ tier_evaluation_window: 'Fenêtre d''évaluation des niveaux'
+ tier_window_calendar_year: Année civile
+ tier_window_lifetime: À vie
+ tier_window_rolling_12_months: 12 mois glissants
+ rounding_ceil: Arrondi supérieur
+ rounding_floor: Arrondi inférieur
+ rounding_round: Arrondi au plus proche
+ promotion_rule:
+ customer_loyalty_tier: Le niveau de fidélité du client est au moins
+ minimum_tier: Niveau minimum
+ tier:
+ benefits: Avantages
+ benefits_description: Description des avantages
+ color: Couleur du badge
+ earning_multiplier: Multiplicateur de gain
+ earning_multiplier_help: Appliqué à chaque gain de points tant que le compte est à ce niveau (1 = aucun changement)
+ position_help: Les positions plus élevées correspondent à des niveaux plus élevés ; la position la plus élevée est le niveau supérieur
+ qualification_basis: Base de qualification
+ threshold: Seuil
+ threshold_help: 'L''unité dépend de la base (points, unités mineures de devise ou commandes)'
+ tier_basis:
+ amount_spent: Montant dépensé
+ orders_count: Commandes passées
+ points_earned: Points gagnés
+ unit:
+ currency: unités mineures de devise
+ orders: commandes
+ points: points
+ trigger:
+ customer_birthday: Anniversaire du client
+ customer_registered: Client inscrit
+ order_eligible: Commande (intégré)
+ product_review_approved: Avis produit approuvé
+ ui:
+ account: Compte de fidélité
+ account_inactive: 'Votre compte de fidélité est actuellement inactif — votre solde est conservé, mais le gain et l''utilisation de points sont suspendus.'
+ accounts: Comptes de fidélité
+ accounts_card_description: Parcourir les comptes, inspecter les registres, ajuster les points
+ adjust: Ajuster
+ anonymized: Anonymisé
+ balance: Votre solde de points
+ balance_label: Solde
+ change_on_cart: Modifier sur le panier
+ consumptions: Consommé par
+ dashboard_subtitle: Points, règles de gain et paramètres du programme
+ derived_balance: Somme du registre
+ dry_run: Mode simulation
+ dry_run_results: Résultats de simulation
+ dry_run_results_card_description: Ce que les règles en mode simulation auraient attribué
+ earn_hint_cart: 'Cette commande rapporte ~%points% points.'
+ earn_hint_product: 'Vous gagnerez %points% points en achetant ce produit.'
+ earning_rate_hint: 'Le taux de gain est défini par les règles de gain : une règle de base au niveau de la commande avec le type « points par montant » constitue le taux par défaut de la boutique.'
+ earning_rules: Règles de gain
+ earning_rules_card_description: Déclencheurs, conditions et montants
+ edit_earning_rule: Modifier la règle de gain
+ edit_program: Modifier le programme
+ new_earning_rule: Nouvelle règle de gain
+ description: Description
+ expired: Expiré
+ expires: 'expire le %date%'
+ expires_at: Expire le
+ expiring_soon: '%points% points expirent avant le %date%.'
+ history: Historique des transactions
+ history_description:
+ clawback: 'Points récupérés pour une commande annulée ou remboursée'
+ earn_action: 'Points gagnés'
+ earn_order: 'Gagnés sur la commande %order%'
+ earn_referral: 'Récompense de parrainage'
+ expire: 'Points expirés'
+ manual_credit: 'Ajustement : %reason%'
+ manual_debit: 'Ajustement : %reason%'
+ redeem: 'Utilisés sur la commande %order%'
+ redeem_rollback: 'Points restitués suite à une commande annulée'
+ history_empty: 'Aucune transaction pour l''instant — les points que vous gagnez et dépensez apparaîtront ici.'
+ my_loyalty: Ma fidélité
+ running_balance: Solde
+ inspect: Inspecter
+ invariants_hold: Tous les invariants du registre sont respectés.
+ invariants_violated: Les invariants du registre sont violés — enquêtez avant toute correction manuelle.
+ ledger: Registre
+ lifetime_earned: Gagné au total
+ lot: Lot
+ lots: Lots (dérivés par rejeu)
+ loyalty: Fidélité
+ manual_adjustment: Ajustement manuel
+ manual_reason:
+ correction: Correction
+ goodwill: Geste commercial
+ other: Autre
+ promotion: Promotion
+ never: jamais
+ note: Note
+ points: Points
+ points_amount: '%points% points'
+ points_applied: '%points% points appliqués'
+ points_redemption: Utilisation de points
+ program: Programme
+ program_card_description: Conversion, expiration et politiques par canal
+ reason: Raison
+ refer_a_friend: Parrainez un ami
+ referee: Filleul
+ referral_explainer: 'Partagez votre lien — lorsqu''un ami passe sa première commande, vous gagnez tous les deux des points.'
+ referral_override: Approuver quand même
+ referral_stats: 'Vous avez %count% parrainage(s) récompensé(s), qui ont rapporté %points% points.'
+ referral_status:
+ expired: Expiré
+ pending: En attente
+ qualified: Qualifié
+ rejected: Rejeté
+ rewarded: Récompensé
+ referrals: Parrainages
+ referrals_card_description: Statuts des parrainages et dérogations antifraude
+ referrer: Parrain
+ redemption_clamped: 'Votre demande de %requested% points n''est appliquée que partiellement à cette commande ; elle augmentera automatiquement dès que la commande le permettra.'
+ remaining: Restant
+ remove_redemption: Retirer
+ rule: Règle
+ rule_tester: Testeur de règles
+ rule_tester_card_description: Évaluer les règles sur une commande récente, éventuellement à une autre date
+ scope: Portée
+ share: Partager
+ copied: Copié !
+ stat_accounts: Comptes
+ stat_active_accounts_90_days: Comptes actifs (90 jours)
+ stat_liability: Passif de points en circulation
+ stat_redemption_rate_90_days: 'Taux d''utilisation (90 jours)'
+ stat_earned_30_days: Points gagnés (30 jours)
+ stat_redeemed_30_days: Points utilisés (30 jours)
+ tester_applied: Appliqué
+ tester_claimed_basis: Base revendiquée
+ tester_claimed_by: Revendiqué par
+ tester_evaluate: Évaluer
+ tester_evaluate_at: 'Évaluer comme si c''était'
+ tester_evaluate_at_help: 'Prévisualiser les règles planifiées avant l''ouverture de leur période'
+ tester_failed_conditions: Conditions non remplies
+ tester_final_award: 'Attribution finale : %points% points'
+ tester_invalid_date: 'La date d''évaluation est invalide.'
+ tester_item_claims: Revendications par article
+ tester_matched: Correspond
+ tester_order_has_no_customer: 'La commande n''a pas de client, aucune règle ne peut donc être évaluée.'
+ tester_order_not_found: 'Aucune commande avec ce numéro n''a été trouvée.'
+ tester_order_number: Numéro de commande
+ tester_rules: Évaluations des règles
+ tier: Niveau
+ tier_progress: '%metric% / %threshold% vers %tier%'
+ tier_top: 'Vous avez atteint le niveau le plus élevé — merci d''être l''un de nos tout meilleurs clients !'
+ tiers: Niveaux
+ tiers_card_description: Niveaux, seuils, multiplicateurs et avantages
+ trigger: Déclencheur
+ use_max: Utiliser le maximum
+ your_code: Votre code
+ use_your_points: Utilisez vos points
diff --git a/src/Resources/translations/messages.hu.yaml b/src/Resources/translations/messages.hu.yaml
new file mode 100644
index 0000000..0a303cb
--- /dev/null
+++ b/src/Resources/translations/messages.hu.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Feltétel hozzáadása
+ amount_configuration: Összeg beállítása
+ amount_configuration_help: 'JSON, pl. {"points": 1, "per_amount": 100} az összeg utáni ponthoz, {"points": 50} a fix pontszámhoz, {"factor": 2} a szorzóhoz, {"expression": "floor(basis / 100)"} a kifejezéshez'
+ amount_expression: Összeg kifejezése
+ amount_expression_help: Akkor használatos, ha az összeg típusa „Kifejezés”; a "basis" változó a szabály által lefoglalt alapot tartalmazza a pénznem legkisebb egységében
+ amount_requires_order_trigger: Az összeg utáni és a szorzó típushoz rendelési alap szükséges, ezért csak a beépített rendelési eseményindítóval érhetők el.
+ amount_type: Összeg típusa
+ condition_configuration: Feltétel beállítása
+ condition_configuration_help: 'JSON, pl. {"amount": 5000} vagy {"taxons": ["t_shirts"]}'
+ condition_expression: Feltétel kifejezése
+ condition_expression_help: Akkor használatos, ha a feltétel típusa „Kifejezés”; a feltétel akkor teljesül, ha a kifejezés igaz értékű
+ condition_type: Feltétel típusa
+ expression_required: A kifejezés módhoz kifejezés szükséges.
+ multiplier_is_order_scoped: A szorzós szabályok csak a teljes rendelésre vonatkoznak; tételszintű logikához használja a kifejezés módot.
+ scope_requires_order_trigger: A taxon- és termékhatókörhöz rendelési tételek szükségesek, ezért csak a beépített rendelési eseményindítóval érhetők el.
+ conditions: Feltételek
+ conditions_match: Feltételek teljesülése
+ conditions_match_all: Minden feltételnek teljesülnie kell
+ conditions_match_any: Legalább egy feltételnek teljesülnie kell
+ dry_run: Próbaüzem
+ dry_run_help: Kiértékelés az éles forgalmon, és annak naplózása az auditlistába, hogy mennyi pont járna — pontok jóváírása nélkül
+ ends_at: Vége
+ scope: Hatókör
+ scope_configuration: Hatókör beállítása
+ scope_configuration_help: 'JSON, pl. {"taxons": ["t_shirts"]} vagy {"products": ["MUG"]}; hagyja üresen a rendelés hatókörhöz'
+ scope_help: A termék- vagy taxonhatókörű szabályok kizárólagosan lefoglalják az egyező tételeket; a rendeléshatókörű szabályok a maradék után adnak pontot
+ scope_order: Teljes rendelés
+ scope_product: Adott termékek
+ scope_taxon: Adott taxonok
+ stackable: Halmozható
+ stackable_help: Az ugyanazon alapért versengő halmozható szabályok mind érvényesülnek és összeadódnak; a nem halmozható szabály egyedül érvényesül (a legmagasabb prioritás nyer)
+ starts_at: Kezdete
+ trigger: Eseményindító
+ window_timezone_help: Az alkalmazásban beállított időzóna szerint kell megadni, és aszerint kerül kiértékelésre
+ earning_rule.amount:
+ expression: Kifejezés
+ fixed: Fix pontszám
+ multiplier: Szorzó
+ per_amount: Pont összeg után
+ earning_rule.condition:
+ cart_contains_taxon: A kosár tartalmazza a taxont
+ customer_group: Vásárlói csoport
+ date_window: Időszak
+ day_of_week: A hét napja
+ expression: Kifejezés
+ nth_order: N-edik rendelés
+ order_total_at_least: Rendelés végösszege legalább
+ invalid_json: Ez az érték nem érvényes JSON.
+ program:
+ award_moment_order_fulfilled: Amikor a rendelés teljesült
+ award_moment_payment_paid: Amikor a fizetés teljes egészében megtörtént
+ award_order_points_at: Rendelési pontok jóváírása ekkor
+ award_order_points_at_help: A rendelés életciklusának egyetlen pillanata, amikor a pontok jóváírásra kerülnek — programonként egy, sosem szabályonként
+ base_currency_help: Az összegek a csatorna alappénznemének legkisebb egységében értendők
+ clawback_policy: Visszavonási szabályzat
+ clawback_policy_allow_negative: Negatív egyenleg engedélyezése (a jövőbeli pontszerzésből térül meg)
+ clawback_policy_clamp_to_zero: Az egyenleg nullára korlátozása
+ earning_basis: Pontszerzési alap
+ earning_basis_help: Mi számít bele a pontszerzésbe; magát a pontszerzési arányt a pontszerzési szabályok határozzák meg
+ earning_basis_items_total: Tételek összege (szállítás nélkül)
+ earning_basis_order_total: Rendelés végösszege (szállítással)
+ include_taxes: Adók beszámítása az alapba
+ max_redeem_percent_of_order: A rendelés max. beváltható százaléka
+ max_redeem_percent_of_order_help: A rendelési tételek összegének pontokkal fedezhető része (100 esetén a rendelés teljesen ingyenes lehet)
+ min_redeem_points: Minimális pontszám beváltásonként
+ points_expiry_days: A pontok lejárnak ennyi nap után
+ points_expiry_days_help: Hagyja üresen, ha a pontok soha nem járnak le. Vegye figyelembe, hogy a tárolt érték lejárata egyes joghatóságokban fogyasztóvédelmi korlátozások alá esik
+ redemption_conversion_amount: '... ennyit ér (legkisebb pénzegység)'
+ redemption_conversion_help: 'A beváltási átváltás: ennyi pont...'
+ redemption_conversion_points: Pontegység
+ retroactive_guest_points: Visszamenőleges vendégpontok
+ retroactive_guest_points_help: Pontok jóváírása a regisztráció előtti vendégrendelésekért, amikor a vendég regisztrál (korábbi regisztrációkra nincs visszamenőleges jóváírás)
+ rounding: Kerekítés
+ show_earnable_in_cart: Pontszerzési tipp megjelenítése a kosárban
+ show_earnable_on_product: Pontszerzési tipp megjelenítése a termékoldalakon
+ tier_downgrade_grace_days: Türelmi idő a szint visszaminősítése előtt (nap)
+ tier_downgrade_grace_days_help: Meddig tartja meg a fiók a szintjét, miután a küszöb alá esett; 0 esetén a következő éjszakai kiértékeléskor visszaminősül
+ tier_evaluation_window: Szintkiértékelési időszak
+ tier_window_calendar_year: Naptári év
+ tier_window_lifetime: Teljes élettartam
+ tier_window_rolling_12_months: Gördülő 12 hónap
+ rounding_ceil: Felfelé
+ rounding_floor: Lefelé
+ rounding_round: Matematikai
+ promotion_rule:
+ customer_loyalty_tier: A vásárló hűségszintje legalább
+ minimum_tier: Minimális szint
+ tier:
+ benefits: Előnyök
+ benefits_description: Előnyök leírása
+ color: Jelvény színe
+ earning_multiplier: Pontszerzési szorzó
+ earning_multiplier_help: Minden pontszerzésre alkalmazva, amíg a fiók ezen a szinten van (1 = nincs változás)
+ position_help: A magasabb pozíciók magasabb szintek; a legmagasabb pozíció a legfelső szint
+ qualification_basis: Minősítési alap
+ threshold: Küszöbérték
+ threshold_help: A mértékegység az alaptól függ (pontok, a pénznem legkisebb egységei vagy rendelések)
+ tier_basis:
+ amount_spent: Elköltött összeg
+ orders_count: Leadott rendelések
+ points_earned: Szerzett pontok
+ unit:
+ currency: a pénznem legkisebb egységei
+ orders: rendelés
+ points: pont
+ trigger:
+ customer_birthday: Vásárló születésnapja
+ customer_registered: Vásárló regisztrált
+ order_eligible: Rendelés (beépített)
+ product_review_approved: Termékértékelés jóváhagyva
+ ui:
+ account: Hűségfiók
+ account_inactive: Hűségfiókja jelenleg inaktív — egyenlege megmarad, de a pontszerzés és a beváltás szünetel.
+ accounts: Hűségfiókok
+ accounts_card_description: Fiókok böngészése, főkönyvek megtekintése, pontok módosítása
+ adjust: Módosítás
+ anonymized: Anonimizálva
+ balance: Az Ön pontegyenlege
+ balance_label: Egyenleg
+ change_on_cart: Módosítás a kosárban
+ consumptions: Felhasználta
+ dashboard_subtitle: Pontok, pontszerzési szabályok és programbeállítások
+ derived_balance: Főkönyvi összeg
+ dry_run: Próbaüzem
+ dry_run_results: Próbaüzem eredményei
+ dry_run_results_card_description: Amit a próbaüzemben lévő szabályok jóváírtak volna
+ earn_hint_cart: 'Ezzel a rendeléssel ~%points% pontot szerez.'
+ earn_hint_product: 'E termék megvásárlásával %points% pontot szerez.'
+ earning_rate_hint: 'A pontszerzési arányt a pontszerzési szabályok határozzák meg: egy rendeléshatókörű alapszabály az összeg utáni típussal a bolt alapértelmezett aránya.'
+ earning_rules: Pontszerzési szabályok
+ earning_rules_card_description: Eseményindítók, feltételek és összegek
+ edit_earning_rule: Pontszerzési szabály szerkesztése
+ edit_program: Program szerkesztése
+ new_earning_rule: Új pontszerzési szabály
+ description: Leírás
+ expired: Lejárt
+ expires: 'lejár: %date%'
+ expires_at: Lejárat
+ expiring_soon: '%points% pont lejár %date% előtt.'
+ history: Tranzakciós előzmények
+ history_description:
+ clawback: 'Törölt vagy visszatérített rendelés miatt visszavont pontok'
+ earn_action: 'Szerzett pontok'
+ earn_order: 'A(z) %order% rendelésen szerzett pontok'
+ earn_referral: 'Ajánlási jutalom'
+ expire: 'Lejárt pontok'
+ manual_credit: 'Módosítás: %reason%'
+ manual_debit: 'Módosítás: %reason%'
+ redeem: 'Beváltva a(z) %order% rendelésnél'
+ redeem_rollback: 'Törölt rendelésből visszaállított pontok'
+ history_empty: Még nincsenek tranzakciók — a megszerzett és elköltött pontok itt jelennek meg.
+ my_loyalty: Hűségprogramom
+ running_balance: Egyenleg
+ inspect: Megtekintés
+ invariants_hold: Minden főkönyvi invariáns teljesül.
+ invariants_violated: A főkönyvi invariánsok sérültek — kézi javítás előtt vizsgálja ki az okot.
+ ledger: Főkönyv
+ lifetime_earned: Összesen szerzett
+ lot: Pontcsomag
+ lots: Pontcsomagok (visszajátszásból származtatva)
+ loyalty: Hűségprogram
+ manual_adjustment: Kézi módosítás
+ manual_reason:
+ correction: Javítás
+ goodwill: Méltányosság
+ other: Egyéb
+ promotion: Promóció
+ never: soha
+ note: Megjegyzés
+ points: Pontok
+ points_amount: '%points% pont'
+ points_applied: '%points% pont felhasználva'
+ points_redemption: Pontbeváltás
+ program: Program
+ program_card_description: Csatornánkénti átváltás, lejárat és szabályzatok
+ reason: Indok
+ refer_a_friend: Ajánljon egy barátjának
+ referee: Ajánlott
+ referral_explainer: Ossza meg a linkjét — amikor egy barátja leadja első rendelését, mindketten pontokat kapnak.
+ referral_override: Jóváhagyás mindenképp
+ referral_stats: 'Önnek %count% jutalmazott ajánlása van, amelyek %points% pontot hoztak.'
+ referral_status:
+ expired: Lejárt
+ pending: Függőben
+ qualified: Megfelelt
+ rejected: Elutasítva
+ rewarded: Jutalmazva
+ referrals: Ajánlások
+ referrals_card_description: Ajánlási állapotok és csalásellenőrzési felülbírálások
+ referrer: Ajánló
+ redemption_clamped: 'Az Ön %requested% pontos kérése csak részben kerül felhasználásra ezen a rendelésen; automatikusan visszanő, amint a rendelés lehetővé teszi.'
+ remaining: Fennmaradó
+ remove_redemption: Eltávolítás
+ rule: Szabály
+ rule_tester: Szabálytesztelő
+ rule_tester_card_description: Szabályok kiértékelése egy friss rendelésen, akár más dátummal
+ scope: Hatókör
+ share: Megosztás
+ copied: Másolva!
+ stat_accounts: Fiókok
+ stat_active_accounts_90_days: Aktív fiókok (90 nap)
+ stat_liability: Fennálló pontkötelezettség
+ stat_redemption_rate_90_days: Beváltási arány (90 nap)
+ stat_earned_30_days: Szerzett pontok (30 nap)
+ stat_redeemed_30_days: Beváltott pontok (30 nap)
+ tester_applied: Alkalmazva
+ tester_claimed_basis: Lefoglalt alap
+ tester_claimed_by: Lefoglalta
+ tester_evaluate: Kiértékelés
+ tester_evaluate_at: Kiértékelés úgy, mintha ekkor lenne
+ tester_evaluate_at_help: Ütemezett szabályok előnézete az időszakuk megnyílása előtt
+ tester_failed_conditions: Nem teljesült feltételek
+ tester_final_award: 'Végső jóváírás: %points% pont'
+ tester_invalid_date: A kiértékelési dátum érvénytelen.
+ tester_item_claims: Tételenkénti foglalások
+ tester_matched: Egyezett
+ tester_order_has_no_customer: A rendeléshez nem tartozik vásárló, ezért egyetlen szabály sem értékelhető ki.
+ tester_order_not_found: Nem található rendelés ezzel a számmal.
+ tester_order_number: Rendelésszám
+ tester_rules: Szabálykiértékelések
+ tier: Szint
+ tier_progress: '%metric% / %threshold% a(z) %tier% szintig'
+ tier_top: Elérte a legfelső szintet — köszönjük, hogy legjobb vásárlóink egyike!
+ tiers: Szintek
+ tiers_card_description: Szintek, küszöbértékek, szorzók és előnyök
+ trigger: Eseményindító
+ use_max: Maximum használata
+ your_code: Az Ön kódja
+ use_your_points: Használja fel pontjait
diff --git a/src/Resources/translations/messages.it.yaml b/src/Resources/translations/messages.it.yaml
new file mode 100644
index 0000000..12fe07e
--- /dev/null
+++ b/src/Resources/translations/messages.it.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Aggiungi condizione
+ amount_configuration: 'Configurazione dell''importo'
+ amount_configuration_help: 'JSON, ad es. {"points": 1, "per_amount": 100} per importo proporzionale, {"points": 50} per fisso, {"factor": 2} per moltiplicatore, {"expression": "floor(basis / 100)"} per espressione'
+ amount_expression: 'Espressione dell''importo'
+ amount_expression_help: Usata quando il tipo di importo è «Espressione»; la variabile «basis» contiene la base rivendicata dalla regola in unità minori
+ amount_requires_order_trigger: 'I tipi per importo e moltiplicatore richiedono una base d''ordine e sono disponibili solo per il trigger d''ordine integrato.'
+ amount_type: Tipo di importo
+ condition_configuration: Configurazione della condizione
+ condition_configuration_help: 'JSON, ad es. {"amount": 5000} o {"taxons": ["t_shirts"]}'
+ condition_expression: Espressione della condizione
+ condition_expression_help: 'Usata quando il tipo di condizione è «Espressione»; la condizione è soddisfatta quando l''espressione è vera'
+ condition_type: Tipo di condizione
+ expression_required: 'La modalità espressione richiede un''espressione.'
+ multiplier_is_order_scoped: 'Le regole moltiplicatore si applicano solo all''intero ordine; usa la modalità espressione per la logica a livello di articolo.'
+ scope_requires_order_trigger: 'Gli ambiti taxon e prodotto richiedono articoli d''ordine e sono disponibili solo per il trigger d''ordine integrato.'
+ conditions: Condizioni
+ conditions_match: Corrispondenza delle condizioni
+ conditions_match_all: Tutte le condizioni devono essere soddisfatte
+ conditions_match_any: Almeno una condizione deve essere soddisfatta
+ dry_run: Simulazione
+ dry_run_help: 'Valuta sul traffico reale e registra ciò che sarebbe stato assegnato nell''elenco di audit, senza accreditare alcun punto'
+ ends_at: Termina il
+ scope: Ambito
+ scope_configuration: 'Configurazione dell''ambito'
+ scope_configuration_help: 'JSON, ad es. {"taxons": ["t_shirts"]} o {"products": ["MUG"]}; lasciare vuoto per l''ambito ordine'
+ scope_help: Le regole limitate a prodotto o taxon rivendicano in esclusiva gli articoli corrispondenti; le regole a livello di ordine guadagnano sul resto
+ scope_order: Intero ordine
+ scope_product: Prodotti specifici
+ scope_taxon: Taxon specifici
+ stackable: Cumulabile
+ stackable_help: Le regole cumulabili in concorrenza per la stessa base si applicano tutte e si sommano; una regola non cumulabile si applica da sola (vince la priorità più alta)
+ starts_at: Inizia il
+ trigger: Trigger
+ window_timezone_help: 'Inserito e valutato nel fuso orario configurato dell''applicazione'
+ earning_rule.amount:
+ expression: Espressione
+ fixed: Punti fissi
+ multiplier: Moltiplicatore
+ per_amount: Punti per importo
+ earning_rule.condition:
+ cart_contains_taxon: Il carrello contiene un taxon
+ customer_group: Gruppo di clienti
+ date_window: Intervallo di date
+ day_of_week: Giorno della settimana
+ expression: Espressione
+ nth_order: Ennesimo ordine
+ order_total_at_least: Totale ordine di almeno
+ invalid_json: Questo valore non è un JSON valido.
+ program:
+ award_moment_order_fulfilled: 'Quando l''ordine è evaso'
+ award_moment_payment_paid: Quando il pagamento è interamente saldato
+ award_order_points_at: 'Assegna i punti dell''ordine al momento'
+ award_order_points_at_help: 'L''unico momento del ciclo di vita dell''ordine in cui i punti vengono assegnati — uno per programma, mai per regola'
+ base_currency_help: Gli importi sono in unità minori della valuta di base del canale
+ clawback_policy: Politica di recupero
+ clawback_policy_allow_negative: Consenti saldo negativo (recuperato dai guadagni futuri)
+ clawback_policy_clamp_to_zero: Limita il saldo a zero
+ earning_basis: Base di accumulo
+ earning_basis_help: 'Ciò che conta ai fini dell''accumulo; il tasso di accumulo è definito dalle regole di accumulo'
+ earning_basis_items_total: Totale articoli (spedizione esclusa)
+ earning_basis_order_total: Totale ordine (spedizione inclusa)
+ include_taxes: Includi le imposte nella base
+ max_redeem_percent_of_order: 'Percentuale max. riscattabile dell''ordine'
+ max_redeem_percent_of_order_help: 'La quota del totale articoli dell''ordine copribile con i punti (100 consente ordini interamente gratuiti)'
+ min_redeem_points: Punti minimi per riscatto
+ points_expiry_days: I punti scadono dopo (giorni)
+ points_expiry_days_help: Lasciare vuoto perché i punti non scadano mai. Nota che la scadenza del valore accumulato è soggetta a vincoli di legge sui consumatori in alcune giurisdizioni
+ redemption_conversion_amount: '... valgono questo importo (unità minori)'
+ redemption_conversion_help: 'La conversione di riscatto: questo numero di punti...'
+ redemption_conversion_points: Unità di punti
+ retroactive_guest_points: Punti ospite retroattivi
+ retroactive_guest_points_help: 'Assegna punti per gli ordini effettuati come ospite prima della registrazione quando l''ospite si registra (nessun recupero per registrazioni precedenti)'
+ rounding: Arrotondamento
+ show_earnable_in_cart: Mostra il suggerimento sui punti nel carrello
+ show_earnable_on_product: Mostra il suggerimento sui punti nelle pagine prodotto
+ tier_downgrade_grace_days: Tolleranza prima della retrocessione di livello (giorni)
+ tier_downgrade_grace_days_help: Per quanto tempo un account mantiene il proprio livello dopo essere sceso sotto la soglia; 0 retrocede alla successiva valutazione notturna
+ tier_evaluation_window: Finestra di valutazione dei livelli
+ tier_window_calendar_year: Anno solare
+ tier_window_lifetime: A vita
+ tier_window_rolling_12_months: 12 mesi mobili
+ rounding_ceil: Per eccesso
+ rounding_floor: Per difetto
+ rounding_round: Al più vicino
+ promotion_rule:
+ customer_loyalty_tier: Il livello fedeltà del cliente è almeno
+ minimum_tier: Livello minimo
+ tier:
+ benefits: Vantaggi
+ benefits_description: Descrizione dei vantaggi
+ color: Colore del badge
+ earning_multiplier: Moltiplicatore di accumulo
+ earning_multiplier_help: 'Applicato a ogni accumulo di punti finché l''account è in questo livello (1 = nessuna modifica)'
+ position_help: Le posizioni più alte sono livelli più alti; la posizione più alta è il livello massimo
+ qualification_basis: Base di qualificazione
+ threshold: Soglia
+ threshold_help: 'L''unità dipende dalla base (punti, unità minori di valuta o ordini)'
+ tier_basis:
+ amount_spent: Importo speso
+ orders_count: Ordini effettuati
+ points_earned: Punti guadagnati
+ unit:
+ currency: unità minori di valuta
+ orders: ordini
+ points: punti
+ trigger:
+ customer_birthday: Compleanno del cliente
+ customer_registered: Cliente registrato
+ order_eligible: Ordine (integrato)
+ product_review_approved: Recensione prodotto approvata
+ ui:
+ account: Account fedeltà
+ account_inactive: 'Il tuo account fedeltà è attualmente inattivo — il tuo saldo è conservato, ma l''accumulo e il riscatto sono sospesi.'
+ accounts: Account fedeltà
+ accounts_card_description: Sfoglia gli account, ispeziona i registri, regola i punti
+ adjust: Regola
+ anonymized: Anonimizzato
+ balance: Il tuo saldo punti
+ balance_label: Saldo
+ change_on_cart: Modifica nel carrello
+ consumptions: Consumato da
+ dashboard_subtitle: Punti, regole di accumulo e impostazioni del programma
+ derived_balance: Somma del registro
+ dry_run: Simulazione
+ dry_run_results: Risultati della simulazione
+ dry_run_results_card_description: Ciò che le regole in simulazione avrebbero assegnato
+ earn_hint_cart: 'Questo ordine fa guadagnare ~%points% punti.'
+ earn_hint_product: 'Guadagnerai %points% punti acquistando questo prodotto.'
+ earning_rate_hint: 'Il tasso di accumulo è definito dalle regole di accumulo: una regola base a livello di ordine con il tipo «punti per importo» è il tasso predefinito del negozio.'
+ earning_rules: Regole di accumulo
+ earning_rules_card_description: Trigger, condizioni e importi
+ edit_earning_rule: Modifica regola di accumulo
+ edit_program: Modifica programma
+ new_earning_rule: Nuova regola di accumulo
+ description: Descrizione
+ expired: Scaduto
+ expires: 'scade il %date%'
+ expires_at: Scade il
+ expiring_soon: '%points% punti scadono prima del %date%.'
+ history: Cronologia delle transazioni
+ history_description:
+ clawback: 'Punti recuperati per un ordine annullato o rimborsato'
+ earn_action: 'Punti guadagnati'
+ earn_order: 'Guadagnati sull''ordine %order%'
+ earn_referral: 'Premio per referral'
+ expire: 'Punti scaduti'
+ manual_credit: 'Rettifica: %reason%'
+ manual_debit: 'Rettifica: %reason%'
+ redeem: 'Riscattati sull''ordine %order%'
+ redeem_rollback: 'Punti ripristinati da un ordine annullato'
+ history_empty: Nessuna transazione al momento — i punti che guadagni e spendi appariranno qui.
+ my_loyalty: La mia fedeltà
+ running_balance: Saldo
+ inspect: Ispeziona
+ invariants_hold: Tutti gli invarianti del registro sono rispettati.
+ invariants_violated: Gli invarianti del registro sono violati — indaga prima di effettuare correzioni manuali.
+ ledger: Registro
+ lifetime_earned: Guadagnati in totale
+ lot: Lotto
+ lots: Lotti (derivati dal replay)
+ loyalty: Fedeltà
+ manual_adjustment: Rettifica manuale
+ manual_reason:
+ correction: Correzione
+ goodwill: Gesto di cortesia
+ other: Altro
+ promotion: Promozione
+ never: mai
+ note: Nota
+ points: Punti
+ points_amount: '%points% punti'
+ points_applied: '%points% punti applicati'
+ points_redemption: Riscatto punti
+ program: Programma
+ program_card_description: Conversione, scadenza e politiche per canale
+ reason: Motivo
+ refer_a_friend: Presenta un amico
+ referee: Invitato
+ referral_explainer: Condividi il tuo link — quando un amico effettua il suo primo ordine, guadagnate punti entrambi.
+ referral_override: Approva comunque
+ referral_stats: 'Hai %count% referral premiati, che hanno fruttato %points% punti.'
+ referral_status:
+ expired: Scaduto
+ pending: In attesa
+ qualified: Qualificato
+ rejected: Rifiutato
+ rewarded: Premiato
+ referrals: Referral
+ referrals_card_description: Stati dei referral e forzature antifrode
+ referrer: Presentatore
+ redemption_clamped: 'La tua richiesta di %requested% punti è applicata solo parzialmente a questo ordine; aumenterà di nuovo automaticamente quando l''ordine lo consentirà.'
+ remaining: Rimanenti
+ remove_redemption: Rimuovi
+ rule: Regola
+ rule_tester: Tester delle regole
+ rule_tester_card_description: Valuta le regole su un ordine recente, facoltativamente a una data diversa
+ scope: Ambito
+ share: Condividi
+ copied: Copiato!
+ stat_accounts: Account
+ stat_active_accounts_90_days: Account attivi (90 giorni)
+ stat_liability: Passività punti in essere
+ stat_redemption_rate_90_days: Tasso di riscatto (90 giorni)
+ stat_earned_30_days: Punti guadagnati (30 giorni)
+ stat_redeemed_30_days: Punti riscattati (30 giorni)
+ tester_applied: Applicato
+ tester_claimed_basis: Base rivendicata
+ tester_claimed_by: Rivendicato da
+ tester_evaluate: Valuta
+ tester_evaluate_at: Valuta come se fosse
+ tester_evaluate_at_help: 'Anteprima delle regole pianificate prima dell''apertura del loro periodo'
+ tester_failed_conditions: Condizioni non soddisfatte
+ tester_final_award: 'Assegnazione finale: %points% punti'
+ tester_invalid_date: La data di valutazione non è valida.
+ tester_item_claims: Rivendicazioni per articolo
+ tester_matched: Corrisponde
+ tester_order_has_no_customer: 'L''ordine non ha un cliente, quindi nessuna regola può essere valutata.'
+ tester_order_not_found: Nessun ordine trovato con quel numero.
+ tester_order_number: 'Numero d''ordine'
+ tester_rules: Valutazioni delle regole
+ tier: Livello
+ tier_progress: '%metric% / %threshold% per %tier%'
+ tier_top: Hai raggiunto il livello più alto — grazie per essere uno dei nostri migliori clienti!
+ tiers: Livelli
+ tiers_card_description: Livelli, soglie, moltiplicatori e vantaggi
+ trigger: Trigger
+ use_max: Usa il massimo
+ your_code: Il tuo codice
+ use_your_points: Usa i tuoi punti
diff --git a/src/Resources/translations/messages.nl.yaml b/src/Resources/translations/messages.nl.yaml
new file mode 100644
index 0000000..deb0e1a
--- /dev/null
+++ b/src/Resources/translations/messages.nl.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Voorwaarde toevoegen
+ amount_configuration: Bedragconfiguratie
+ amount_configuration_help: 'JSON, bijv. {"points": 1, "per_amount": 100} voor per bedrag, {"points": 50} voor vast, {"factor": 2} voor vermenigvuldiger, {"expression": "floor(basis / 100)"} voor expressie'
+ amount_expression: Bedragexpressie
+ amount_expression_help: Wordt gebruikt wanneer het bedragtype "Expressie" is; de variabele "basis" bevat de door de regel geclaimde basis in de kleinste munteenheid
+ amount_requires_order_trigger: De typen per bedrag en vermenigvuldiger hebben een bestelbasis nodig en zijn alleen beschikbaar voor de ingebouwde besteltrigger.
+ amount_type: Bedragtype
+ condition_configuration: Voorwaardeconfiguratie
+ condition_configuration_help: 'JSON, bijv. {"amount": 5000} of {"taxons": ["t_shirts"]}'
+ condition_expression: Voorwaarde-expressie
+ condition_expression_help: Wordt gebruikt wanneer het voorwaardetype "Expressie" is; de voorwaarde slaagt wanneer de expressie waar is
+ condition_type: Voorwaardetype
+ expression_required: De expressiemodus vereist een expressie.
+ multiplier_is_order_scoped: Vermenigvuldigingsregels gelden alleen op bestelniveau; gebruik de expressiemodus voor logica op artikelniveau.
+ scope_requires_order_trigger: De taxon- en productbereiken hebben bestelartikelen nodig en zijn alleen beschikbaar voor de ingebouwde besteltrigger.
+ conditions: Voorwaarden
+ conditions_match: Voorwaarden matchen
+ conditions_match_all: Aan alle voorwaarden moet worden voldaan
+ conditions_match_any: Aan ten minste één voorwaarde moet worden voldaan
+ dry_run: Proefdraaien
+ dry_run_help: Evalueer op live verkeer en log wat zou zijn toegekend in de auditlijst, zonder punten weg te schrijven
+ ends_at: Eindigt op
+ scope: Bereik
+ scope_configuration: Bereikconfiguratie
+ scope_configuration_help: 'JSON, bijv. {"taxons": ["t_shirts"]} of {"products": ["MUG"]}; leeg laten voor bestelbereik'
+ scope_help: Product- of taxongebonden regels claimen hun overeenkomende artikelen exclusief; regels op bestelniveau sparen over de rest
+ scope_order: Hele bestelling
+ scope_product: Specifieke producten
+ scope_taxon: Specifieke taxons
+ stackable: Stapelbaar
+ stackable_help: Stapelbare regels die om dezelfde basis strijden, gelden allemaal en worden opgeteld; een niet-stapelbare regel geldt alleen (hoogste prioriteit wint)
+ starts_at: Begint op
+ trigger: Trigger
+ window_timezone_help: Ingevoerd en geëvalueerd in de geconfigureerde tijdzone van de applicatie
+ earning_rule.amount:
+ expression: Expressie
+ fixed: Vaste punten
+ multiplier: Vermenigvuldiger
+ per_amount: Punten per bedrag
+ earning_rule.condition:
+ cart_contains_taxon: Winkelwagen bevat taxon
+ customer_group: Klantengroep
+ date_window: Datumperiode
+ day_of_week: Dag van de week
+ expression: Expressie
+ nth_order: N-de bestelling
+ order_total_at_least: Besteltotaal minimaal
+ invalid_json: Deze waarde is geen geldige JSON.
+ program:
+ award_moment_order_fulfilled: Wanneer de bestelling is afgehandeld
+ award_moment_payment_paid: Wanneer de betaling volledig is voldaan
+ award_order_points_at: Bestelpunten toekennen bij
+ award_order_points_at_help: Het enige moment in de levenscyclus van de bestelling waarop punten worden toegekend — één per programma, nooit per regel
+ base_currency_help: Bedragen zijn in de kleinste munteenheid van de basisvaluta van het kanaal
+ clawback_policy: Terugvorderingsbeleid
+ clawback_policy_allow_negative: Negatief saldo toestaan (verrekend met toekomstige punten)
+ clawback_policy_clamp_to_zero: Saldo begrenzen op nul
+ earning_basis: Spaarbasis
+ earning_basis_help: Wat meetelt voor het sparen; het spaartarief zelf wordt bepaald door spaarregels
+ earning_basis_items_total: Artikeltotaal (excl. verzending)
+ earning_basis_order_total: Besteltotaal (incl. verzending)
+ include_taxes: Belastingen meenemen in de basis
+ max_redeem_percent_of_order: Max. inwisselbaar percentage van de bestelling
+ max_redeem_percent_of_order_help: Het deel van het artikeltotaal van de bestelling dat met punten gedekt kan worden (100 staat volledig gratis bestellingen toe)
+ min_redeem_points: Minimaal aantal punten per inwisseling
+ points_expiry_days: Punten verlopen na (dagen)
+ points_expiry_days_help: Laat leeg zodat punten nooit verlopen. Let op dat het laten verlopen van opgebouwd tegoed in sommige rechtsgebieden onderworpen is aan consumentenwetgeving
+ redemption_conversion_amount: '... zijn dit bedrag waard (kleinste munteenheid)'
+ redemption_conversion_help: 'De inwisselconversie: dit aantal punten...'
+ redemption_conversion_points: Punteneenheid
+ retroactive_guest_points: Gastpunten met terugwerkende kracht
+ retroactive_guest_points_help: Ken punten toe voor gastbestellingen van vóór de registratie wanneer de gast zich registreert (geen toekenning achteraf voor eerdere registraties)
+ rounding: Afronding
+ show_earnable_in_cart: Toon de spaarhint in de winkelwagen
+ show_earnable_on_product: 'Toon de spaarhint op productpagina''s'
+ tier_downgrade_grace_days: Respijt bij niveauverlaging (dagen)
+ tier_downgrade_grace_days_help: Hoe lang een account zijn niveau behoudt na onder de drempel te zijn gezakt; 0 verlaagt bij de eerstvolgende nachtelijke evaluatie
+ tier_evaluation_window: Evaluatievenster voor niveaus
+ tier_window_calendar_year: Kalenderjaar
+ tier_window_lifetime: Levenslang
+ tier_window_rolling_12_months: Voortschrijdende 12 maanden
+ rounding_ceil: Naar boven
+ rounding_floor: Naar beneden
+ rounding_round: Afronden
+ promotion_rule:
+ customer_loyalty_tier: Loyaliteitsniveau van de klant is minimaal
+ minimum_tier: Minimumniveau
+ tier:
+ benefits: Voordelen
+ benefits_description: Beschrijving van de voordelen
+ color: Badgekleur
+ earning_multiplier: Spaarvermenigvuldiger
+ earning_multiplier_help: Toegepast op elke puntenbijschrijving zolang het account in dit niveau zit (1 = geen wijziging)
+ position_help: Hogere posities zijn hogere niveaus; de hoogste positie is het topniveau
+ qualification_basis: Kwalificatiegrondslag
+ threshold: Drempel
+ threshold_help: De eenheid hangt af van de grondslag (punten, kleinste munteenheden of bestellingen)
+ tier_basis:
+ amount_spent: Besteed bedrag
+ orders_count: Geplaatste bestellingen
+ points_earned: Gespaarde punten
+ unit:
+ currency: kleinste munteenheden
+ orders: bestellingen
+ points: punten
+ trigger:
+ customer_birthday: Verjaardag van de klant
+ customer_registered: Klant geregistreerd
+ order_eligible: Bestelling (ingebouwd)
+ product_review_approved: Productreview goedgekeurd
+ ui:
+ account: Loyaliteitsaccount
+ account_inactive: Je loyaliteitsaccount is momenteel inactief — je saldo blijft behouden, maar sparen en inwisselen zijn gepauzeerd.
+ accounts: Loyaliteitsaccounts
+ accounts_card_description: Accounts doorbladeren, grootboeken inspecteren, punten aanpassen
+ adjust: Aanpassen
+ anonymized: Geanonimiseerd
+ balance: Je puntensaldo
+ balance_label: Saldo
+ change_on_cart: Wijzigen in de winkelwagen
+ consumptions: Verbruikt door
+ dashboard_subtitle: Punten, spaarregels en programma-instellingen
+ derived_balance: Grootboeksom
+ dry_run: Proefdraaien
+ dry_run_results: Proefdraairesultaten
+ dry_run_results_card_description: Wat proefdraairegels zouden hebben toegekend
+ earn_hint_cart: 'Met deze bestelling spaar je ~%points% punten.'
+ earn_hint_product: 'Je spaart %points% punten bij aankoop van dit product.'
+ earning_rate_hint: 'Het spaartarief wordt bepaald door spaarregels: een basisregel op bestelniveau met het type "punten per bedrag" is het standaardtarief van de winkel.'
+ earning_rules: Spaarregels
+ earning_rules_card_description: Triggers, voorwaarden en bedragen
+ edit_earning_rule: Spaarregel bewerken
+ edit_program: Programma bewerken
+ new_earning_rule: Nieuwe spaarregel
+ description: Beschrijving
+ expired: Verlopen
+ expires: 'verloopt op %date%'
+ expires_at: Verloopt op
+ expiring_soon: '%points% punten verlopen vóór %date%.'
+ history: Transactiegeschiedenis
+ history_description:
+ clawback: 'Punten teruggevorderd voor een geannuleerde of terugbetaalde bestelling'
+ earn_action: 'Punten gespaard'
+ earn_order: 'Gespaard op bestelling %order%'
+ earn_referral: 'Doorverwijzingsbeloning'
+ expire: 'Punten verlopen'
+ manual_credit: 'Correctie: %reason%'
+ manual_debit: 'Correctie: %reason%'
+ redeem: 'Ingewisseld op bestelling %order%'
+ redeem_rollback: 'Punten teruggezet van een geannuleerde bestelling'
+ history_empty: Nog geen transacties — punten die je spaart en uitgeeft, verschijnen hier.
+ my_loyalty: Mijn loyaliteit
+ running_balance: Saldo
+ inspect: Inspecteren
+ invariants_hold: Alle grootboekinvarianten kloppen.
+ invariants_violated: De grootboekinvarianten zijn geschonden — onderzoek dit voordat je handmatige correcties doorvoert.
+ ledger: Grootboek
+ lifetime_earned: Totaal gespaard
+ lot: Partij
+ lots: Partijen (afgeleid via replay)
+ loyalty: Loyaliteit
+ manual_adjustment: Handmatige correctie
+ manual_reason:
+ correction: Correctie
+ goodwill: Coulance
+ other: Overig
+ promotion: Promotie
+ never: nooit
+ note: Notitie
+ points: Punten
+ points_amount: '%points% punten'
+ points_applied: '%points% punten toegepast'
+ points_redemption: Punten inwisselen
+ program: Programma
+ program_card_description: Conversie, vervaldatum en beleid per kanaal
+ reason: Reden
+ refer_a_friend: Verwijs een vriend door
+ referee: Doorverwezen persoon
+ referral_explainer: Deel je link — wanneer een vriend zijn eerste bestelling plaatst, sparen jullie allebei punten.
+ referral_override: Toch goedkeuren
+ referral_stats: 'Je hebt %count% beloonde doorverwijzing(en), goed voor %points% punten.'
+ referral_status:
+ expired: Verlopen
+ pending: In afwachting
+ qualified: Gekwalificeerd
+ rejected: Afgewezen
+ rewarded: Beloond
+ referrals: Doorverwijzingen
+ referrals_card_description: Doorverwijzingsstatussen en het overrulen van fraudecontroles
+ referrer: Doorverwijzer
+ redemption_clamped: 'Je verzoek van %requested% punten wordt slechts gedeeltelijk op deze bestelling toegepast; het groeit automatisch weer aan zodra de bestelling dat toelaat.'
+ remaining: Resterend
+ remove_redemption: Verwijderen
+ rule: Regel
+ rule_tester: Regeltester
+ rule_tester_card_description: Regels evalueren op een recente bestelling, eventueel op een andere datum
+ scope: Bereik
+ share: Delen
+ copied: Gekopieerd!
+ stat_accounts: Accounts
+ stat_active_accounts_90_days: Actieve accounts (90 dagen)
+ stat_liability: Uitstaande puntenverplichting
+ stat_redemption_rate_90_days: Inwisselpercentage (90 dagen)
+ stat_earned_30_days: Punten gespaard (30 dagen)
+ stat_redeemed_30_days: Punten ingewisseld (30 dagen)
+ tester_applied: Toegepast
+ tester_claimed_basis: Geclaimde basis
+ tester_claimed_by: Geclaimd door
+ tester_evaluate: Evalueren
+ tester_evaluate_at: Evalueren alsof het is
+ tester_evaluate_at_help: Bekijk geplande regels voordat hun periode opent
+ tester_failed_conditions: Niet-vervulde voorwaarden
+ tester_final_award: 'Uiteindelijke toekenning: %points% punten'
+ tester_invalid_date: De evaluatiedatum is ongeldig.
+ tester_item_claims: Claims per artikel
+ tester_matched: Match
+ tester_order_has_no_customer: De bestelling heeft geen klant, dus er kunnen geen regels worden geëvalueerd.
+ tester_order_not_found: Er is geen bestelling met dat nummer gevonden.
+ tester_order_number: Bestelnummer
+ tester_rules: Regelevaluaties
+ tier: Niveau
+ tier_progress: '%metric% / %threshold% tot %tier%'
+ tier_top: Je hebt het hoogste niveau bereikt — bedankt dat je een van onze allerbeste klanten bent!
+ tiers: Niveaus
+ tiers_card_description: Niveaus, drempels, vermenigvuldigers en voordelen
+ trigger: Trigger
+ use_max: Maximum gebruiken
+ your_code: Jouw code
+ use_your_points: Gebruik je punten
diff --git a/src/Resources/translations/messages.no.yaml b/src/Resources/translations/messages.no.yaml
new file mode 100644
index 0000000..6ba4878
--- /dev/null
+++ b/src/Resources/translations/messages.no.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Legg til betingelse
+ amount_configuration: Beløpskonfigurasjon
+ amount_configuration_help: 'JSON, f.eks. {"points": 1, "per_amount": 100} for per beløp, {"points": 50} for fast, {"factor": 2} for multiplikator, {"expression": "floor(basis / 100)"} for uttrykk'
+ amount_expression: Beløpsuttrykk
+ amount_expression_help: Brukes når beløpstypen er "Uttrykk"; variabelen "basis" inneholder grunnlaget regelen gjør krav på, i minste enheter
+ amount_requires_order_trigger: Typene per beløp og multiplikator krever et ordregrunnlag og er kun tilgjengelige for den innebygde ordre-triggeren.
+ amount_type: Beløpstype
+ condition_configuration: Betingelseskonfigurasjon
+ condition_configuration_help: 'JSON, f.eks. {"amount": 5000} eller {"taxons": ["t_shirts"]}'
+ condition_expression: Betingelsesuttrykk
+ condition_expression_help: Brukes når betingelsestypen er "Uttrykk"; betingelsen er oppfylt når uttrykket evalueres som sant
+ condition_type: Betingelsestype
+ expression_required: Uttrykksmodus krever et uttrykk.
+ multiplier_is_order_scoped: Multiplikatorregler gjelder kun hele ordren; bruk uttrykksmodus for logikk på varelinjenivå.
+ scope_requires_order_trigger: Taxon- og produktavgrensninger krever ordrelinjer og er kun tilgjengelige for den innebygde ordre-triggeren.
+ conditions: Betingelser
+ conditions_match: Match av betingelser
+ conditions_match_all: Alle betingelser må være oppfylt
+ conditions_match_any: Minst én betingelse må være oppfylt
+ dry_run: Testkjøring
+ dry_run_help: Evaluer mot reell trafikk og logg til revisjonslisten hva som ville blitt tildelt, uten å skrive noen point
+ ends_at: Slutter
+ scope: Avgrensning
+ scope_configuration: Avgrensningskonfigurasjon
+ scope_configuration_help: 'JSON, f.eks. {"taxons": ["t_shirts"]} eller {"products": ["MUG"]}; la feltet stå tomt for hele ordren'
+ scope_help: Produkt- eller taxonavgrensede regler gjør eksklusivt krav på sine matchende varer; ordreavgrensede regler opptjener på resten
+ scope_order: Hele ordren
+ scope_product: Bestemte produkter
+ scope_taxon: Bestemte taxoner
+ stackable: Kan kombineres
+ stackable_help: Regler som kan kombineres og konkurrerer om samme grunnlag, gjelder alle og summeres; en regel som ikke kan kombineres, gjelder alene (høyeste prioritet vinner)
+ starts_at: Starter
+ trigger: Trigger
+ window_timezone_help: Angis og evalueres i applikasjonens konfigurerte tidssone
+ earning_rule.amount:
+ expression: Uttrykk
+ fixed: Faste point
+ multiplier: Multiplikator
+ per_amount: Point per beløp
+ earning_rule.condition:
+ cart_contains_taxon: Handlekurven inneholder taxon
+ customer_group: Kundegruppe
+ date_window: Datointervall
+ day_of_week: Ukedag
+ expression: Uttrykk
+ nth_order: N-te ordre
+ order_total_at_least: Ordretotal minst
+ invalid_json: Denne verdien er ikke gyldig JSON.
+ program:
+ award_moment_order_fulfilled: Når ordren er fullført
+ award_moment_payment_paid: Når betalingen er fullt betalt
+ award_order_points_at: Tidspunkt for tildeling av ordrepoint
+ award_order_points_at_help: Det ene tidspunktet i ordreforløpet der point tildeles — ett per program, aldri per regel
+ base_currency_help: Beløp angis i minste enheter av kanalens basisvaluta
+ clawback_policy: Tilbakeføringspolicy
+ clawback_policy_allow_negative: Tillat negativ saldo (inndrives fra fremtidig opptjening)
+ clawback_policy_clamp_to_zero: Begrens saldoen til null
+ earning_basis: Opptjeningsgrunnlag
+ earning_basis_help: Hva som teller med i opptjeningen; selve opptjeningssatsen defineres av opptjeningsregler
+ earning_basis_items_total: Varetotal (ekskl. frakt)
+ earning_basis_order_total: Ordretotal (inkl. frakt)
+ include_taxes: Inkluder mva. i grunnlaget
+ max_redeem_percent_of_order: Maks. innløsbar prosent av ordren
+ max_redeem_percent_of_order_help: Andelen av ordrens varetotal som kan dekkes av point (100 tillater helt gratis ordrer)
+ min_redeem_points: Minimum point per innløsning
+ points_expiry_days: Point utløper etter (dager)
+ points_expiry_days_help: La feltet stå tomt slik at point aldri utløper. Merk at utløp av oppspart verdi kan være underlagt forbrukerrettslige begrensninger i enkelte jurisdiksjoner
+ redemption_conversion_amount: '... er verdt dette beløpet (minste enheter)'
+ redemption_conversion_help: 'Innløsningskursen: så mange point...'
+ redemption_conversion_points: Pointenhet
+ retroactive_guest_points: Point for tidligere gjesteordrer
+ retroactive_guest_points_help: Tildel point for gjesteordrer lagt inn før registrering når gjesten registrerer seg (ingen etterbetaling for tidligere registreringer)
+ rounding: Avrunding
+ show_earnable_in_cart: Vis opptjeningshintet i handlekurven
+ show_earnable_on_product: Vis opptjeningshintet på produktsider
+ tier_downgrade_grace_days: Frist ved nivånedrykk (dager)
+ tier_downgrade_grace_days_help: Hvor lenge en konto beholder nivået sitt etter å ha falt under terskelen; 0 nedgraderer ved neste nattlige evaluering
+ tier_evaluation_window: Evalueringsvindu for nivåer
+ tier_window_calendar_year: Kalenderår
+ tier_window_lifetime: Levetid
+ tier_window_rolling_12_months: Rullerende 12 måneder
+ rounding_ceil: Rund opp
+ rounding_floor: Rund ned
+ rounding_round: Avrund
+ promotion_rule:
+ customer_loyalty_tier: Kundens lojalitetsnivå er minst
+ minimum_tier: Minimumsnivå
+ tier:
+ benefits: Fordeler
+ benefits_description: Beskrivelse av fordelene
+ color: Badgefarge
+ earning_multiplier: Opptjeningsmultiplikator
+ earning_multiplier_help: Brukes på all pointopptjening mens kontoen er på dette nivået (1 = ingen endring)
+ position_help: Høyere posisjoner er høyere nivåer; den høyeste posisjonen er toppnivået
+ qualification_basis: Kvalifiseringsgrunnlag
+ threshold: Terskel
+ threshold_help: Enheten avhenger av grunnlaget (point, minste valutaenheter eller ordrer)
+ tier_basis:
+ amount_spent: Beløp brukt
+ orders_count: Ordrer lagt inn
+ points_earned: Opptjente point
+ unit:
+ currency: minste valutaenheter
+ orders: ordrer
+ points: point
+ trigger:
+ customer_birthday: Kundens fødselsdag
+ customer_registered: Kunde registrert
+ order_eligible: Ordre (innebygd)
+ product_review_approved: Produktanmeldelse godkjent
+ ui:
+ account: Lojalitetskonto
+ account_inactive: Lojalitetskontoen din er for øyeblikket inaktiv — saldoen din er bevart, men opptjening og innløsning er satt på pause.
+ accounts: Lojalitetskontoer
+ accounts_card_description: Bla gjennom kontoer, inspiser hovedbøker, juster point
+ adjust: Juster
+ anonymized: Anonymisert
+ balance: Din pointsaldo
+ balance_label: Saldo
+ change_on_cart: Endre i handlekurven
+ consumptions: Forbrukt av
+ dashboard_subtitle: Point, opptjeningsregler og programinnstillinger
+ derived_balance: Sum i hovedboken
+ dry_run: Testkjøring
+ dry_run_results: Testkjøringsresultater
+ dry_run_results_card_description: Hva testkjøringsregler ville ha tildelt
+ earn_hint_cart: 'Denne ordren gir ~%points% point.'
+ earn_hint_product: 'Du tjener %points% point ved kjøp av dette produktet.'
+ earning_rate_hint: 'Opptjeningssatsen defineres av opptjeningsregler: en basisregel for hele ordren med typen per beløp er butikkens standardsats.'
+ earning_rules: Opptjeningsregler
+ earning_rules_card_description: Triggere, betingelser og beløp
+ edit_earning_rule: Rediger opptjeningsregel
+ edit_program: Rediger program
+ new_earning_rule: Ny opptjeningsregel
+ description: Beskrivelse
+ expired: Utløpt
+ expires: 'utløper %date%'
+ expires_at: Utløper
+ expiring_soon: '%points% point utløper før %date%.'
+ history: Transaksjonshistorikk
+ history_description:
+ clawback: 'Point tilbakeført for en kansellert eller refundert ordre'
+ earn_action: 'Point opptjent'
+ earn_order: 'Opptjent på ordre %order%'
+ earn_referral: 'Vervebelønning'
+ expire: 'Point utløpt'
+ manual_credit: 'Justering: %reason%'
+ manual_debit: 'Justering: %reason%'
+ redeem: 'Innløst på ordre %order%'
+ redeem_rollback: 'Point gjenopprettet fra en kansellert ordre'
+ history_empty: Ingen transaksjoner ennå — point du opptjener og bruker, vises her.
+ my_loyalty: Mitt lojalitetsprogram
+ running_balance: Saldo
+ inspect: Inspiser
+ invariants_hold: Alle hovedbokens invarianter holder.
+ invariants_violated: Hovedbokens invarianter er brutt — undersøk saken før du gjør manuelle korrigeringer.
+ ledger: Hovedbok
+ lifetime_earned: Opptjent totalt
+ lot: Lot
+ lots: Lots (avledet ved reavspilling)
+ loyalty: Lojalitet
+ manual_adjustment: Manuell justering
+ manual_reason:
+ correction: Korrigering
+ goodwill: Goodwill
+ other: Annet
+ promotion: Kampanje
+ never: aldri
+ note: Notat
+ points: Point
+ points_amount: '%points% point'
+ points_applied: '%points% point brukt'
+ points_redemption: Pointinnløsning
+ program: Program
+ program_card_description: Konvertering, utløp og policyer per kanal
+ reason: Årsak
+ refer_a_friend: Verv en venn
+ referee: Vervet
+ referral_explainer: Del lenken din — når en venn legger inn sin første ordre, tjener dere begge point.
+ referral_override: Godkjenn likevel
+ referral_stats: 'Du har %count% belønnede verving(er) som har gitt %points% point.'
+ referral_status:
+ expired: Utløpt
+ pending: Venter
+ qualified: Kvalifisert
+ rejected: Avvist
+ rewarded: Belønnet
+ referrals: Vervinger
+ referrals_card_description: Vervingsstatuser og overstyring av svindelsjekker
+ referrer: Verver
+ redemption_clamped: 'Forespørselen din om %requested% point er bare delvis brukt på denne ordren; den økes automatisk igjen når ordren tillater det.'
+ remaining: Gjenstående
+ remove_redemption: Fjern
+ rule: Regel
+ rule_tester: Regeltester
+ rule_tester_card_description: Evaluer regler mot en nylig ordre, eventuelt på en annen dato
+ scope: Avgrensning
+ share: Del
+ copied: Kopiert!
+ stat_accounts: Kontoer
+ stat_active_accounts_90_days: Aktive kontoer (90 dager)
+ stat_liability: Utestående pointforpliktelse
+ stat_redemption_rate_90_days: Innløsningsgrad (90 dager)
+ stat_earned_30_days: Point opptjent (30 dager)
+ stat_redeemed_30_days: Point innløst (30 dager)
+ tester_applied: Brukt
+ tester_claimed_basis: Krevd grunnlag
+ tester_claimed_by: Krevd av
+ tester_evaluate: Evaluer
+ tester_evaluate_at: Evaluer som om det var
+ tester_evaluate_at_help: Forhåndsvis planlagte regler før tidsvinduet deres åpner
+ tester_failed_conditions: Ikke-oppfylte betingelser
+ tester_final_award: 'Endelig tildeling: %points% point'
+ tester_invalid_date: Evalueringsdatoen er ugyldig.
+ tester_item_claims: Krav per varelinje
+ tester_matched: Matchet
+ tester_order_has_no_customer: Ordren har ingen kunde, så ingen regler kan evalueres.
+ tester_order_not_found: Ingen ordre med det nummeret ble funnet.
+ tester_order_number: Ordrenummer
+ tester_rules: Regelevalueringer
+ tier: Nivå
+ tier_progress: '%metric% / %threshold% til %tier%'
+ tier_top: Du har nådd det høyeste nivået — takk for at du er en av våre aller beste kunder!
+ tiers: Nivåer
+ tiers_card_description: Nivåer, terskler, multiplikatorer og fordeler
+ trigger: Trigger
+ use_max: Bruk maks.
+ your_code: Din kode
+ use_your_points: Bruk pointene dine
diff --git a/src/Resources/translations/messages.pl.yaml b/src/Resources/translations/messages.pl.yaml
new file mode 100644
index 0000000..07c8708
--- /dev/null
+++ b/src/Resources/translations/messages.pl.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Dodaj warunek
+ amount_configuration: Konfiguracja kwoty
+ amount_configuration_help: 'JSON, np. {"points": 1, "per_amount": 100} dla punktów za kwotę, {"points": 50} dla stałej liczby, {"factor": 2} dla mnożnika, {"expression": "floor(basis / 100)"} dla wyrażenia'
+ amount_expression: Wyrażenie kwoty
+ amount_expression_help: Używane, gdy typ kwoty to "Wyrażenie"; zmienna "basis" zawiera podstawę zajętą przez regułę w jednostkach mniejszych
+ amount_requires_order_trigger: Typy za kwotę i mnożnik wymagają podstawy zamówienia i są dostępne tylko dla wbudowanego wyzwalacza zamówienia.
+ amount_type: Typ kwoty
+ condition_configuration: Konfiguracja warunku
+ condition_configuration_help: 'JSON, np. {"amount": 5000} lub {"taxons": ["t_shirts"]}'
+ condition_expression: Wyrażenie warunku
+ condition_expression_help: Używane, gdy typ warunku to "Wyrażenie"; warunek jest spełniony, gdy wyrażenie jest prawdziwe
+ condition_type: Typ warunku
+ expression_required: Tryb wyrażenia wymaga wyrażenia.
+ multiplier_is_order_scoped: Reguły z mnożnikiem działają tylko na poziomie zamówienia; do logiki na poziomie pozycji użyj trybu wyrażenia.
+ scope_requires_order_trigger: Zakresy taksonu i produktu wymagają pozycji zamówienia i są dostępne tylko dla wbudowanego wyzwalacza zamówienia.
+ conditions: Warunki
+ conditions_match: Dopasowanie warunków
+ conditions_match_all: Wszystkie warunki muszą być spełnione
+ conditions_match_any: Co najmniej jeden warunek musi być spełniony
+ dry_run: Tryb testowy
+ dry_run_help: Oceniaj na rzeczywistym ruchu i zapisuj na liście audytu, co zostałoby przyznane, bez zapisywania żadnych punktów
+ ends_at: Kończy się
+ scope: Zakres
+ scope_configuration: Konfiguracja zakresu
+ scope_configuration_help: 'JSON, np. {"taxons": ["t_shirts"]} lub {"products": ["MUG"]}; pozostaw puste dla zakresu zamówienia'
+ scope_help: Reguły ograniczone do produktu lub taksonu zajmują na wyłączność pasujące pozycje; reguły na poziomie zamówienia naliczają punkty od reszty
+ scope_order: Całe zamówienie
+ scope_product: Wybrane produkty
+ scope_taxon: Wybrane taksony
+ stackable: Łączy się z innymi regułami
+ stackable_help: Reguły łączące się, które konkurują o tę samą podstawę, stosują się wszystkie i sumują; reguła niełącząca się stosuje się samodzielnie (wygrywa najwyższy priorytet)
+ starts_at: Zaczyna się
+ trigger: Wyzwalacz
+ window_timezone_help: Wprowadzane i oceniane w skonfigurowanej strefie czasowej aplikacji
+ earning_rule.amount:
+ expression: Wyrażenie
+ fixed: Stała liczba punktów
+ multiplier: Mnożnik
+ per_amount: Punkty za kwotę
+ earning_rule.condition:
+ cart_contains_taxon: Koszyk zawiera takson
+ customer_group: Grupa klientów
+ date_window: Przedział dat
+ day_of_week: Dzień tygodnia
+ expression: Wyrażenie
+ nth_order: N-te zamówienie
+ order_total_at_least: Suma zamówienia co najmniej
+ invalid_json: Ta wartość nie jest prawidłowym JSON-em.
+ program:
+ award_moment_order_fulfilled: Gdy zamówienie zostanie zrealizowane
+ award_moment_payment_paid: Gdy płatność zostanie w pełni opłacona
+ award_order_points_at: Przyznawaj punkty za zamówienie w momencie
+ award_order_points_at_help: Jedyny moment cyklu życia zamówienia, w którym przyznawane są punkty — jeden na program, nigdy na regułę
+ base_currency_help: Kwoty są w jednostkach mniejszych waluty bazowej kanału
+ clawback_policy: Polityka odbierania punktów
+ clawback_policy_allow_negative: Zezwalaj na ujemne saldo (odzyskiwane z przyszłych punktów)
+ clawback_policy_clamp_to_zero: Ogranicz saldo do zera
+ earning_basis: Podstawa naliczania
+ earning_basis_help: Co liczy się do naliczania punktów; samą stawkę naliczania określają reguły naliczania
+ earning_basis_items_total: Suma pozycji (bez wysyłki)
+ earning_basis_order_total: Suma zamówienia (z wysyłką)
+ include_taxes: Uwzględnij podatki w podstawie
+ max_redeem_percent_of_order: Maks. procent zamówienia do wymiany
+ max_redeem_percent_of_order_help: Część sumy pozycji zamówienia, którą można pokryć punktami (100 pozwala na całkowicie darmowe zamówienia)
+ min_redeem_points: Minimalna liczba punktów na wymianę
+ points_expiry_days: Punkty wygasają po (dniach)
+ points_expiry_days_help: Pozostaw puste, aby punkty nigdy nie wygasały. Pamiętaj, że wygasanie zgromadzonej wartości podlega w niektórych jurysdykcjach przepisom prawa konsumenckiego
+ redemption_conversion_amount: '... są warte tę kwotę (jednostki mniejsze)'
+ redemption_conversion_help: 'Przelicznik wymiany: tyle punktów...'
+ redemption_conversion_points: Jednostka punktów
+ retroactive_guest_points: Punkty wsteczne dla gości
+ retroactive_guest_points_help: Przyznawaj punkty za zamówienia złożone jako gość przed rejestracją, gdy gość się zarejestruje (bez naliczania wstecz dla wcześniejszych rejestracji)
+ rounding: Zaokrąglanie
+ show_earnable_in_cart: Pokazuj podpowiedź o punktach w koszyku
+ show_earnable_on_product: Pokazuj podpowiedź o punktach na stronach produktów
+ tier_downgrade_grace_days: Okres ochronny przed obniżeniem poziomu (dni)
+ tier_downgrade_grace_days_help: Jak długo konto zachowuje swój poziom po spadku poniżej progu; 0 obniża poziom przy najbliższej nocnej ocenie
+ tier_evaluation_window: Okno oceny poziomów
+ tier_window_calendar_year: Rok kalendarzowy
+ tier_window_lifetime: Cały okres
+ tier_window_rolling_12_months: Ostatnie 12 miesięcy (kroczące)
+ rounding_ceil: W górę
+ rounding_floor: W dół
+ rounding_round: Do najbliższej
+ promotion_rule:
+ customer_loyalty_tier: Poziom lojalnościowy klienta wynosi co najmniej
+ minimum_tier: Minimalny poziom
+ tier:
+ benefits: Korzyści
+ benefits_description: Opis korzyści
+ color: Kolor odznaki
+ earning_multiplier: Mnożnik naliczania
+ earning_multiplier_help: Stosowany do każdego naliczenia punktów, gdy konto jest na tym poziomie (1 = bez zmian)
+ position_help: Wyższe pozycje to wyższe poziomy; najwyższa pozycja to najwyższy poziom
+ qualification_basis: Podstawa kwalifikacji
+ threshold: Próg
+ threshold_help: Jednostka zależy od podstawy (punkty, jednostki mniejsze waluty lub zamówienia)
+ tier_basis:
+ amount_spent: Wydana kwota
+ orders_count: Złożone zamówienia
+ points_earned: Zdobyte punkty
+ unit:
+ currency: jednostki mniejsze waluty
+ orders: zamówienia
+ points: punkty
+ trigger:
+ customer_birthday: Urodziny klienta
+ customer_registered: Rejestracja klienta
+ order_eligible: Zamówienie (wbudowany)
+ product_review_approved: Zatwierdzona recenzja produktu
+ ui:
+ account: Konto lojalnościowe
+ account_inactive: Twoje konto lojalnościowe jest obecnie nieaktywne — saldo jest zachowane, ale zdobywanie i wymiana punktów są wstrzymane.
+ accounts: Konta lojalnościowe
+ accounts_card_description: Przeglądaj konta, sprawdzaj księgi, koryguj punkty
+ adjust: Koryguj
+ anonymized: Zanonimizowano
+ balance: Twoje saldo punktów
+ balance_label: Saldo
+ change_on_cart: Zmień w koszyku
+ consumptions: Zużyte przez
+ dashboard_subtitle: Punkty, reguły naliczania i ustawienia programu
+ derived_balance: Suma księgi
+ dry_run: Tryb testowy
+ dry_run_results: Wyniki trybu testowego
+ dry_run_results_card_description: Co przyznałyby reguły w trybie testowym
+ earn_hint_cart: 'To zamówienie da ~%points% punktów.'
+ earn_hint_product: 'Kupując ten produkt, zdobędziesz %points% punktów.'
+ earning_rate_hint: 'Stawkę naliczania określają reguły naliczania: podstawowa reguła na poziomie zamówienia z typem "punkty za kwotę" to domyślna stawka sklepu.'
+ earning_rules: Reguły naliczania
+ earning_rules_card_description: Wyzwalacze, warunki i kwoty
+ edit_earning_rule: Edytuj regułę naliczania
+ edit_program: Edytuj program
+ new_earning_rule: Nowa reguła naliczania
+ description: Opis
+ expired: Wygasłe
+ expires: 'wygasa %date%'
+ expires_at: Wygasa
+ expiring_soon: '%points% punktów wygaśnie przed %date%.'
+ history: Historia transakcji
+ history_description:
+ clawback: 'Punkty odebrane za anulowane lub zwrócone zamówienie'
+ earn_action: 'Punkty zdobyte'
+ earn_order: 'Zdobyte za zamówienie %order%'
+ earn_referral: 'Nagroda za polecenie'
+ expire: 'Punkty wygasły'
+ manual_credit: 'Korekta: %reason%'
+ manual_debit: 'Korekta: %reason%'
+ redeem: 'Wymienione przy zamówieniu %order%'
+ redeem_rollback: 'Punkty przywrócone z anulowanego zamówienia'
+ history_empty: Brak transakcji — punkty, które zdobędziesz i wydasz, pojawią się tutaj.
+ my_loyalty: Mój program lojalnościowy
+ running_balance: Saldo
+ inspect: Sprawdź
+ invariants_hold: Wszystkie niezmienniki księgi są zachowane.
+ invariants_violated: Niezmienniki księgi zostały naruszone — zbadaj sprawę przed wprowadzeniem ręcznych korekt.
+ ledger: Księga
+ lifetime_earned: Zdobyte łącznie
+ lot: Partia
+ lots: Partie (wyliczone z odtworzenia)
+ loyalty: Program lojalnościowy
+ manual_adjustment: Korekta ręczna
+ manual_reason:
+ correction: Korekta
+ goodwill: Gest dobrej woli
+ other: Inne
+ promotion: Promocja
+ never: nigdy
+ note: Notatka
+ points: Punkty
+ points_amount: '%points% punktów'
+ points_applied: 'Zastosowano %points% punktów'
+ points_redemption: Wymiana punktów
+ program: Program
+ program_card_description: Przelicznik, wygasanie i zasady dla każdego kanału
+ reason: Powód
+ refer_a_friend: Poleć znajomemu
+ referee: Polecony
+ referral_explainer: Udostępnij swój link — gdy znajomy złoży swoje pierwsze zamówienie, oboje zdobędziecie punkty.
+ referral_override: Zatwierdź mimo to
+ referral_stats: 'Masz %count% nagrodzonych poleceń, które dały %points% punktów.'
+ referral_status:
+ expired: Wygasłe
+ pending: Oczekujące
+ qualified: Zakwalifikowane
+ rejected: Odrzucone
+ rewarded: Nagrodzone
+ referrals: Polecenia
+ referrals_card_description: Statusy poleceń i obejścia kontroli antyfraudowych
+ referrer: Polecający
+ redemption_clamped: 'Twoje żądanie %requested% punktów jest zastosowane do tego zamówienia tylko częściowo; zwiększy się automatycznie, gdy zamówienie na to pozwoli.'
+ remaining: Pozostało
+ remove_redemption: Usuń
+ rule: Reguła
+ rule_tester: Tester reguł
+ rule_tester_card_description: Oceń reguły na niedawnym zamówieniu, opcjonalnie na inną datę
+ scope: Zakres
+ share: Udostępnij
+ copied: Skopiowano!
+ stat_accounts: Konta
+ stat_active_accounts_90_days: Aktywne konta (90 dni)
+ stat_liability: Zobowiązanie z tytułu punktów
+ stat_redemption_rate_90_days: Wskaźnik wymiany (90 dni)
+ stat_earned_30_days: Punkty zdobyte (30 dni)
+ stat_redeemed_30_days: Punkty wymienione (30 dni)
+ tester_applied: Zastosowano
+ tester_claimed_basis: Zajęta podstawa
+ tester_claimed_by: Zajęte przez
+ tester_evaluate: Oceń
+ tester_evaluate_at: Oceń tak, jakby było
+ tester_evaluate_at_help: Podejrzyj zaplanowane reguły, zanim otworzy się ich okno
+ tester_failed_conditions: Niespełnione warunki
+ tester_final_award: 'Ostateczne przyznanie: %points% punktów'
+ tester_invalid_date: Data oceny jest nieprawidłowa.
+ tester_item_claims: Zajęcia według pozycji
+ tester_matched: Dopasowano
+ tester_order_has_no_customer: Zamówienie nie ma klienta, więc nie można ocenić żadnych reguł.
+ tester_order_not_found: Nie znaleziono zamówienia o tym numerze.
+ tester_order_number: Numer zamówienia
+ tester_rules: Oceny reguł
+ tier: Poziom
+ tier_progress: '%metric% / %threshold% do %tier%'
+ tier_top: Masz już najwyższy poziom — dziękujemy, że należysz do grona naszych najlepszych klientów!
+ tiers: Poziomy
+ tiers_card_description: Poziomy, progi, mnożniki i korzyści
+ trigger: Wyzwalacz
+ use_max: Użyj maksimum
+ your_code: Twój kod
+ use_your_points: Wykorzystaj swoje punkty
diff --git a/src/Resources/translations/messages.pt.yaml b/src/Resources/translations/messages.pt.yaml
new file mode 100644
index 0000000..aff04db
--- /dev/null
+++ b/src/Resources/translations/messages.pt.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Adicionar condição
+ amount_configuration: Configuração do montante
+ amount_configuration_help: 'JSON, p. ex. {"points": 1, "per_amount": 100} para pontos por montante, {"points": 50} para fixo, {"factor": 2} para multiplicador, {"expression": "floor(basis / 100)"} para expressão'
+ amount_expression: Expressão do montante
+ amount_expression_help: Usada quando o tipo de montante é "Expressão"; a variável "basis" contém a base reivindicada pela regra em unidades menores
+ amount_requires_order_trigger: Os tipos por montante e multiplicador precisam de uma base de encomenda e só estão disponíveis para o gatilho de encomenda incorporado.
+ amount_type: Tipo de montante
+ condition_configuration: Configuração da condição
+ condition_configuration_help: 'JSON, p. ex. {"amount": 5000} ou {"taxons": ["t_shirts"]}'
+ condition_expression: Expressão da condição
+ condition_expression_help: Usada quando o tipo de condição é "Expressão"; a condição passa quando a expressão é verdadeira
+ condition_type: Tipo de condição
+ expression_required: O modo de expressão precisa de uma expressão.
+ multiplier_is_order_scoped: As regras de multiplicador aplicam-se apenas ao âmbito da encomenda; use o modo de expressão para lógica ao nível do item.
+ scope_requires_order_trigger: Os âmbitos de taxon e de produto precisam de itens de encomenda e só estão disponíveis para o gatilho de encomenda incorporado.
+ conditions: Condições
+ conditions_match: Correspondência das condições
+ conditions_match_all: Todas as condições devem passar
+ conditions_match_any: Pelo menos uma condição deve passar
+ dry_run: Simulação
+ dry_run_help: Avaliar com o tráfego real e registar na lista de auditoria o que seria atribuído, sem escrever quaisquer pontos
+ ends_at: Termina em
+ scope: Âmbito
+ scope_configuration: Configuração do âmbito
+ scope_configuration_help: 'JSON, p. ex. {"taxons": ["t_shirts"]} ou {"products": ["MUG"]}; deixe vazio para o âmbito da encomenda'
+ scope_help: As regras com âmbito de produto ou taxon reivindicam exclusivamente os itens correspondentes; as regras com âmbito de encomenda ganham sobre o restante
+ scope_order: Encomenda completa
+ scope_product: Produtos específicos
+ scope_taxon: Taxons específicos
+ stackable: Acumulável
+ stackable_help: As regras acumuláveis que competem pela mesma base aplicam-se todas e somam-se; uma regra não acumulável aplica-se sozinha (ganha a de maior prioridade)
+ starts_at: Começa em
+ trigger: Gatilho
+ window_timezone_help: Introduzido e avaliado no fuso horário configurado da aplicação
+ earning_rule.amount:
+ expression: Expressão
+ fixed: Pontos fixos
+ multiplier: Multiplicador
+ per_amount: Pontos por montante
+ earning_rule.condition:
+ cart_contains_taxon: O carrinho contém o taxon
+ customer_group: Grupo de clientes
+ date_window: Janela de datas
+ day_of_week: Dia da semana
+ expression: Expressão
+ nth_order: Enésima encomenda
+ order_total_at_least: Total da encomenda no mínimo
+ invalid_json: Este valor não é JSON válido.
+ program:
+ award_moment_order_fulfilled: Quando a encomenda é concluída
+ award_moment_payment_paid: Quando o pagamento é totalmente pago
+ award_order_points_at: Atribuir os pontos da encomenda em
+ award_order_points_at_help: O único momento do ciclo de vida da encomenda em que os pontos são atribuídos — um por programa, nunca por regra
+ base_currency_help: Os montantes estão em unidades menores da moeda base do canal
+ clawback_policy: Política de recuperação
+ clawback_policy_allow_negative: Permitir saldo negativo (recuperado de ganhos futuros)
+ clawback_policy_clamp_to_zero: Limitar o saldo a zero
+ earning_basis: Base de ganho
+ earning_basis_help: O que conta para o ganho; a taxa de ganho em si é definida pelas regras de ganho
+ earning_basis_items_total: Total dos itens (excl. envio)
+ earning_basis_order_total: Total da encomenda (incl. envio)
+ include_taxes: Incluir impostos na base
+ max_redeem_percent_of_order: Percentagem máx. resgatável da encomenda
+ max_redeem_percent_of_order_help: A parte do total dos itens da encomenda que pode ser coberta por pontos (100 permite encomendas totalmente gratuitas)
+ min_redeem_points: Pontos mínimos por resgate
+ points_expiry_days: Os pontos expiram após (dias)
+ points_expiry_days_help: Deixe vazio para que os pontos nunca expirem. Note que a expiração de valor armazenado está sujeita a restrições da legislação do consumidor em algumas jurisdições
+ redemption_conversion_amount: '... valem este montante (unidades menores)'
+ redemption_conversion_help: 'A conversão de resgate: este número de pontos...'
+ redemption_conversion_points: Unidade de pontos
+ retroactive_guest_points: Pontos retroativos de convidado
+ retroactive_guest_points_help: Atribuir pontos por encomendas de convidado anteriores ao registo quando o convidado se regista (sem retroatividade para registos anteriores)
+ rounding: Arredondamento
+ show_earnable_in_cart: Mostrar a dica de pontos no carrinho
+ show_earnable_on_product: Mostrar a dica de pontos nas páginas de produto
+ tier_downgrade_grace_days: Tolerância antes da descida de nível (dias)
+ tier_downgrade_grace_days_help: Durante quanto tempo uma conta mantém o seu nível depois de cair abaixo do limiar; 0 desce de nível na avaliação noturna seguinte
+ tier_evaluation_window: Janela de avaliação de níveis
+ tier_window_calendar_year: Ano civil
+ tier_window_lifetime: Vitalício
+ tier_window_rolling_12_months: 12 meses móveis
+ rounding_ceil: Por excesso
+ rounding_floor: Por defeito
+ rounding_round: Arredondar
+ promotion_rule:
+ customer_loyalty_tier: O nível de fidelidade do cliente é pelo menos
+ minimum_tier: Nível mínimo
+ tier:
+ benefits: Benefícios
+ benefits_description: Descrição dos benefícios
+ color: Cor do emblema
+ earning_multiplier: Multiplicador de ganho
+ earning_multiplier_help: Aplicado a cada ganho de pontos enquanto a conta está neste nível (1 = sem alteração)
+ position_help: Posições mais altas são níveis mais altos; a posição mais alta é o nível de topo
+ qualification_basis: Base de qualificação
+ threshold: Limiar
+ threshold_help: A unidade depende da base (pontos, unidades menores de moeda ou encomendas)
+ tier_basis:
+ amount_spent: Montante gasto
+ orders_count: Encomendas efetuadas
+ points_earned: Pontos ganhos
+ unit:
+ currency: unidades menores de moeda
+ orders: encomendas
+ points: pontos
+ trigger:
+ customer_birthday: Aniversário do cliente
+ customer_registered: Cliente registado
+ order_eligible: Encomenda (incorporado)
+ product_review_approved: Avaliação de produto aprovada
+ ui:
+ account: Conta de fidelidade
+ account_inactive: A sua conta de fidelidade está atualmente inativa — o seu saldo está preservado, mas o ganho e o resgate estão em pausa.
+ accounts: Contas de fidelidade
+ accounts_card_description: Consultar contas, inspecionar livros-razão, ajustar pontos
+ adjust: Ajustar
+ anonymized: Anonimizado
+ balance: O seu saldo de pontos
+ balance_label: Saldo
+ change_on_cart: Alterar no carrinho
+ consumptions: Consumido por
+ dashboard_subtitle: Pontos, regras de ganho e definições do programa
+ derived_balance: Soma do livro-razão
+ dry_run: Simulação
+ dry_run_results: Resultados de simulação
+ dry_run_results_card_description: O que as regras em simulação teriam atribuído
+ earn_hint_cart: 'Esta encomenda ganha ~%points% pontos.'
+ earn_hint_product: 'Ganhará %points% pontos ao comprar este produto.'
+ earning_rate_hint: 'A taxa de ganho é definida pelas regras de ganho: uma regra base com âmbito de encomenda e o tipo por montante é a taxa padrão da loja.'
+ earning_rules: Regras de ganho
+ earning_rules_card_description: Gatilhos, condições e montantes
+ edit_earning_rule: Editar regra de ganho
+ edit_program: Editar programa
+ new_earning_rule: Nova regra de ganho
+ description: Descrição
+ expired: Expirado
+ expires: 'expira %date%'
+ expires_at: Expira em
+ expiring_soon: '%points% pontos expiram antes de %date%.'
+ history: Histórico de transações
+ history_description:
+ clawback: 'Pontos recuperados por uma encomenda cancelada ou reembolsada'
+ earn_action: 'Pontos ganhos'
+ earn_order: 'Ganhos na encomenda %order%'
+ earn_referral: 'Recompensa de indicação'
+ expire: 'Pontos expirados'
+ manual_credit: 'Ajuste: %reason%'
+ manual_debit: 'Ajuste: %reason%'
+ redeem: 'Resgatados na encomenda %order%'
+ redeem_rollback: 'Pontos restaurados de uma encomenda cancelada'
+ history_empty: Ainda sem transações — os pontos que ganhar e gastar aparecerão aqui.
+ my_loyalty: A minha fidelidade
+ running_balance: Saldo
+ inspect: Inspecionar
+ invariants_hold: Todos os invariantes do livro-razão são cumpridos.
+ invariants_violated: Os invariantes do livro-razão foram violados — investigue antes de fazer correções manuais.
+ ledger: Livro-razão
+ lifetime_earned: Total ganho acumulado
+ lot: Lote
+ lots: Lotes (derivados por reprodução)
+ loyalty: Fidelidade
+ manual_adjustment: Ajuste manual
+ manual_reason:
+ correction: Correção
+ goodwill: Cortesia
+ other: Outro
+ promotion: Promoção
+ never: nunca
+ note: Nota
+ points: Pontos
+ points_amount: '%points% pontos'
+ points_applied: '%points% pontos aplicados'
+ points_redemption: Resgate de pontos
+ program: Programa
+ program_card_description: Conversão, expiração e políticas por canal
+ reason: Motivo
+ refer_a_friend: Indique um amigo
+ referee: Indicado
+ referral_explainer: Partilhe a sua ligação — quando um amigo fizer a primeira encomenda, ambos ganham pontos.
+ referral_override: Aprovar mesmo assim
+ referral_stats: 'Tem %count% indicação(ões) recompensada(s), que renderam %points% pontos.'
+ referral_status:
+ expired: Expirada
+ pending: Pendente
+ qualified: Qualificada
+ rejected: Rejeitada
+ rewarded: Recompensada
+ referrals: Indicações
+ referrals_card_description: Estados das indicações e anulação de rejeições por fraude
+ referrer: Indicador
+ redemption_clamped: 'O seu pedido de %requested% pontos é aplicado apenas parcialmente a esta encomenda; volta a aumentar automaticamente quando a encomenda o permitir.'
+ remaining: Restante
+ remove_redemption: Remover
+ rule: Regra
+ rule_tester: Testador de regras
+ rule_tester_card_description: Avaliar regras com uma encomenda recente, opcionalmente noutra data
+ scope: Âmbito
+ share: Partilhar
+ copied: Copiado!
+ stat_accounts: Contas
+ stat_active_accounts_90_days: Contas ativas (90 dias)
+ stat_liability: Passivo de pontos em aberto
+ stat_redemption_rate_90_days: Taxa de resgate (90 dias)
+ stat_earned_30_days: Pontos ganhos (30 dias)
+ stat_redeemed_30_days: Pontos resgatados (30 dias)
+ tester_applied: Aplicada
+ tester_claimed_basis: Base reivindicada
+ tester_claimed_by: Reivindicada por
+ tester_evaluate: Avaliar
+ tester_evaluate_at: Avaliar como se fosse
+ tester_evaluate_at_help: Pré-visualizar regras agendadas antes de a sua janela abrir
+ tester_failed_conditions: Condições falhadas
+ tester_final_award: 'Atribuição final: %points% pontos'
+ tester_invalid_date: A data de avaliação é inválida.
+ tester_item_claims: Reivindicações por item
+ tester_matched: Correspondeu
+ tester_order_has_no_customer: A encomenda não tem cliente, pelo que nenhuma regra pode ser avaliada.
+ tester_order_not_found: Não foi encontrada nenhuma encomenda com esse número.
+ tester_order_number: Número da encomenda
+ tester_rules: Avaliações de regras
+ tier: Nível
+ tier_progress: '%metric% / %threshold% até %tier%'
+ tier_top: Atingiu o nível mais alto — obrigado por ser um dos nossos melhores clientes!
+ tiers: Níveis
+ tiers_card_description: Níveis, limiares, multiplicadores e benefícios
+ trigger: Gatilho
+ use_max: Usar o máximo
+ your_code: O seu código
+ use_your_points: Use os seus pontos
diff --git a/src/Resources/translations/messages.ro.yaml b/src/Resources/translations/messages.ro.yaml
new file mode 100644
index 0000000..539d699
--- /dev/null
+++ b/src/Resources/translations/messages.ro.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Adaugă condiție
+ amount_configuration: Configurarea sumei
+ amount_configuration_help: 'JSON, de ex. {"points": 1, "per_amount": 100} pentru puncte per sumă, {"points": 50} pentru fix, {"factor": 2} pentru multiplicator, {"expression": "floor(basis / 100)"} pentru expresie'
+ amount_expression: Expresia sumei
+ amount_expression_help: Folosită când tipul sumei este „Expresie”; variabila "basis" conține baza revendicată de regulă, în subunități monetare
+ amount_requires_order_trigger: Tipurile per sumă și multiplicator au nevoie de o bază de comandă și sunt disponibile doar pentru declanșatorul de comandă încorporat.
+ amount_type: Tipul sumei
+ condition_configuration: Configurarea condiției
+ condition_configuration_help: 'JSON, de ex. {"amount": 5000} sau {"taxons": ["t_shirts"]}'
+ condition_expression: Expresia condiției
+ condition_expression_help: Folosită când tipul condiției este „Expresie”; condiția trece când expresia este adevărată
+ condition_type: Tipul condiției
+ expression_required: Modul expresie are nevoie de o expresie.
+ multiplier_is_order_scoped: Regulile cu multiplicator se aplică doar la nivelul comenzii; folosiți modul expresie pentru logică la nivel de articol.
+ scope_requires_order_trigger: Domeniile pe taxon și pe produs au nevoie de articolele comenzii și sunt disponibile doar pentru declanșatorul de comandă încorporat.
+ conditions: Condiții
+ conditions_match: Potrivirea condițiilor
+ conditions_match_all: Toate condițiile trebuie să treacă
+ conditions_match_any: Cel puțin o condiție trebuie să treacă
+ dry_run: Simulare
+ dry_run_help: Evaluează pe traficul real și înregistrează în lista de audit ce ar fi fost acordat, fără a scrie niciun punct
+ ends_at: Se încheie la
+ scope: Domeniu
+ scope_configuration: Configurarea domeniului
+ scope_configuration_help: 'JSON, de ex. {"taxons": ["t_shirts"]} sau {"products": ["MUG"]}; lăsați gol pentru domeniul comenzii'
+ scope_help: Regulile cu domeniu pe produs sau taxon își revendică exclusiv articolele potrivite; regulile cu domeniu pe comandă acumulează pe restul
+ scope_order: Întreaga comandă
+ scope_product: Produse specifice
+ scope_taxon: Taxoni specifici
+ stackable: Cumulabilă
+ stackable_help: Regulile cumulabile care concurează pentru aceeași bază se aplică toate și se însumează; o regulă necumulabilă se aplică singură (câștigă prioritatea cea mai mare)
+ starts_at: Începe la
+ trigger: Declanșator
+ window_timezone_help: Se introduce și se evaluează în fusul orar configurat al aplicației
+ earning_rule.amount:
+ expression: Expresie
+ fixed: Puncte fixe
+ multiplier: Multiplicator
+ per_amount: Puncte per sumă
+ earning_rule.condition:
+ cart_contains_taxon: Coșul conține taxonul
+ customer_group: Grup de clienți
+ date_window: Interval de date
+ day_of_week: Ziua săptămânii
+ expression: Expresie
+ nth_order: A n-a comandă
+ order_total_at_least: Totalul comenzii de cel puțin
+ invalid_json: Această valoare nu este un JSON valid.
+ program:
+ award_moment_order_fulfilled: Când comanda este finalizată
+ award_moment_payment_paid: Când plata este achitată integral
+ award_order_points_at: Acordă punctele comenzii la
+ award_order_points_at_help: Singurul moment din ciclul de viață al comenzii în care se acordă punctele — unul per program, niciodată per regulă
+ base_currency_help: Sumele sunt în subunități ale monedei de bază a canalului
+ clawback_policy: Politica de recuperare
+ clawback_policy_allow_negative: Permite sold negativ (recuperat din acumulările viitoare)
+ clawback_policy_clamp_to_zero: Limitează soldul la zero
+ earning_basis: Baza de acumulare
+ earning_basis_help: Ce se ia în calcul la acumulare; rata de acumulare propriu-zisă este definită de regulile de acumulare
+ earning_basis_items_total: Totalul articolelor (fără livrare)
+ earning_basis_order_total: Totalul comenzii (cu livrare)
+ include_taxes: Include taxele în bază
+ max_redeem_percent_of_order: Procent max. din comandă utilizabil
+ max_redeem_percent_of_order_help: Partea din totalul articolelor comenzii care poate fi acoperită cu puncte (100 permite comenzi complet gratuite)
+ min_redeem_points: Puncte minime per utilizare
+ points_expiry_days: Punctele expiră după (zile)
+ points_expiry_days_help: Lăsați gol pentru ca punctele să nu expire niciodată. Rețineți că expirarea valorii stocate este supusă, în unele jurisdicții, restricțiilor legislației privind protecția consumatorului
+ redemption_conversion_amount: '... valorează această sumă (subunități)'
+ redemption_conversion_help: 'Conversia la utilizare: acest număr de puncte...'
+ redemption_conversion_points: Unitatea de puncte
+ retroactive_guest_points: Puncte retroactive pentru vizitatori
+ retroactive_guest_points_help: Acordă puncte pentru comenzile plasate ca vizitator înainte de înregistrare, atunci când vizitatorul se înregistrează (fără acordare retroactivă pentru înregistrările anterioare)
+ rounding: Rotunjire
+ show_earnable_in_cart: Afișează indiciul de puncte în coș
+ show_earnable_on_product: Afișează indiciul de puncte pe paginile de produs
+ tier_downgrade_grace_days: Perioadă de grație la retrogradarea nivelului (zile)
+ tier_downgrade_grace_days_help: Cât timp un cont își păstrează nivelul după ce a scăzut sub prag; 0 retrogradează la următoarea evaluare nocturnă
+ tier_evaluation_window: Fereastra de evaluare a nivelurilor
+ tier_window_calendar_year: An calendaristic
+ tier_window_lifetime: Pe viață
+ tier_window_rolling_12_months: 12 luni glisante
+ rounding_ceil: În sus
+ rounding_floor: În jos
+ rounding_round: Matematică
+ promotion_rule:
+ customer_loyalty_tier: Nivelul de fidelitate al clientului este cel puțin
+ minimum_tier: Nivel minim
+ tier:
+ benefits: Beneficii
+ benefits_description: Descrierea beneficiilor
+ color: Culoarea insignei
+ earning_multiplier: Multiplicator de acumulare
+ earning_multiplier_help: Se aplică fiecărei acumulări de puncte cât timp contul este la acest nivel (1 = nicio schimbare)
+ position_help: Pozițiile mai mari sunt niveluri mai înalte; poziția cea mai mare este nivelul de top
+ qualification_basis: Baza de calificare
+ threshold: Prag
+ threshold_help: Unitatea depinde de bază (puncte, subunități ale monedei sau comenzi)
+ tier_basis:
+ amount_spent: Suma cheltuită
+ orders_count: Comenzi plasate
+ points_earned: Puncte acumulate
+ unit:
+ currency: subunități ale monedei
+ orders: comenzi
+ points: puncte
+ trigger:
+ customer_birthday: Ziua de naștere a clientului
+ customer_registered: Client înregistrat
+ order_eligible: Comandă (încorporat)
+ product_review_approved: Recenzie de produs aprobată
+ ui:
+ account: Cont de fidelitate
+ account_inactive: Contul dumneavoastră de fidelitate este momentan inactiv — soldul este păstrat, dar acumularea și utilizarea punctelor sunt suspendate.
+ accounts: Conturi de fidelitate
+ accounts_card_description: Răsfoiți conturile, inspectați registrele, ajustați punctele
+ adjust: Ajustează
+ anonymized: Anonimizat
+ balance: Soldul dumneavoastră de puncte
+ balance_label: Sold
+ change_on_cart: Modifică în coș
+ consumptions: Consumat de
+ dashboard_subtitle: Puncte, reguli de acumulare și setările programului
+ derived_balance: Suma din registru
+ dry_run: Simulare
+ dry_run_results: Rezultatele simulării
+ dry_run_results_card_description: Ce ar fi acordat regulile în simulare
+ earn_hint_cart: 'Această comandă acumulează ~%points% puncte.'
+ earn_hint_product: 'Veți acumula %points% puncte cumpărând acest produs.'
+ earning_rate_hint: 'Rata de acumulare este definită de regulile de acumulare: o regulă de bază la nivel de comandă, de tip per sumă, este rata implicită a magazinului.'
+ earning_rules: Reguli de acumulare
+ earning_rules_card_description: Declanșatoare, condiții și sume
+ edit_earning_rule: Editează regula de acumulare
+ edit_program: Editează programul
+ new_earning_rule: Regulă de acumulare nouă
+ description: Descriere
+ expired: Expirat
+ expires: 'expiră %date%'
+ expires_at: Expiră la
+ expiring_soon: '%points% puncte expiră înainte de %date%.'
+ history: Istoricul tranzacțiilor
+ history_description:
+ clawback: 'Puncte recuperate pentru o comandă anulată sau rambursată'
+ earn_action: 'Puncte acumulate'
+ earn_order: 'Acumulate la comanda %order%'
+ earn_referral: 'Recompensă pentru recomandare'
+ expire: 'Puncte expirate'
+ manual_credit: 'Ajustare: %reason%'
+ manual_debit: 'Ajustare: %reason%'
+ redeem: 'Utilizate la comanda %order%'
+ redeem_rollback: 'Puncte restituite dintr-o comandă anulată'
+ history_empty: Nicio tranzacție încă — punctele pe care le acumulați și le cheltuiți vor apărea aici.
+ my_loyalty: Fidelitatea mea
+ running_balance: Sold
+ inspect: Inspectează
+ invariants_hold: Toți invarianții registrului sunt respectați.
+ invariants_violated: Invarianții registrului sunt încălcați — investigați înainte de a face corecții manuale.
+ ledger: Registru
+ lifetime_earned: Total acumulat
+ lot: Lot
+ lots: Loturi (derivate prin reluare)
+ loyalty: Fidelitate
+ manual_adjustment: Ajustare manuală
+ manual_reason:
+ correction: Corecție
+ goodwill: Bunăvoință
+ other: Altul
+ promotion: Promoție
+ never: niciodată
+ note: Notă
+ points: Puncte
+ points_amount: '%points% puncte'
+ points_applied: '%points% puncte aplicate'
+ points_redemption: Utilizarea punctelor
+ program: Program
+ program_card_description: Conversie, expirare și politici per canal
+ reason: Motiv
+ refer_a_friend: Recomandați unui prieten
+ referee: Persoana recomandată
+ referral_explainer: Distribuiți linkul dumneavoastră — când un prieten plasează prima sa comandă, amândoi acumulați puncte.
+ referral_override: Aprobă oricum
+ referral_stats: 'Aveți %count% recomandări recompensate, care au adus %points% puncte.'
+ referral_status:
+ expired: Expirată
+ pending: În așteptare
+ qualified: Calificată
+ rejected: Respinsă
+ rewarded: Recompensată
+ referrals: Recomandări
+ referrals_card_description: Stările recomandărilor și anularea respingerilor pentru fraudă
+ referrer: Persoana care recomandă
+ redemption_clamped: 'Cererea dumneavoastră de %requested% puncte este aplicată doar parțial acestei comenzi; ea crește la loc automat când comanda o permite.'
+ remaining: Rămas
+ remove_redemption: Elimină
+ rule: Regulă
+ rule_tester: Tester de reguli
+ rule_tester_card_description: Evaluați regulile pe o comandă recentă, opțional la o altă dată
+ scope: Domeniu
+ share: Distribuie
+ copied: Copiat!
+ stat_accounts: Conturi
+ stat_active_accounts_90_days: Conturi active (90 de zile)
+ stat_liability: Datorie de puncte în circulație
+ stat_redemption_rate_90_days: Rata de utilizare (90 de zile)
+ stat_earned_30_days: Puncte acumulate (30 de zile)
+ stat_redeemed_30_days: Puncte utilizate (30 de zile)
+ tester_applied: Aplicată
+ tester_claimed_basis: Baza revendicată
+ tester_claimed_by: Revendicată de
+ tester_evaluate: Evaluează
+ tester_evaluate_at: Evaluează ca și cum ar fi
+ tester_evaluate_at_help: Previzualizați regulile programate înainte de deschiderea intervalului lor
+ tester_failed_conditions: Condiții neîndeplinite
+ tester_final_award: 'Acordare finală: %points% puncte'
+ tester_invalid_date: Data evaluării este invalidă.
+ tester_item_claims: Revendicări per articol
+ tester_matched: S-a potrivit
+ tester_order_has_no_customer: Comanda nu are client, așa că nu se pot evalua reguli.
+ tester_order_not_found: Nu a fost găsită nicio comandă cu acest număr.
+ tester_order_number: Numărul comenzii
+ tester_rules: Evaluările regulilor
+ tier: Nivel
+ tier_progress: '%metric% / %threshold% până la %tier%'
+ tier_top: Ați atins nivelul cel mai înalt — vă mulțumim că sunteți unul dintre cei mai buni clienți ai noștri!
+ tiers: Niveluri
+ tiers_card_description: Niveluri, praguri, multiplicatori și beneficii
+ trigger: Declanșator
+ use_max: Folosește maximul
+ your_code: Codul dumneavoastră
+ use_your_points: Folosiți-vă punctele
diff --git a/src/Resources/translations/messages.sv.yaml b/src/Resources/translations/messages.sv.yaml
new file mode 100644
index 0000000..9de5421
--- /dev/null
+++ b/src/Resources/translations/messages.sv.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Lägg till villkor
+ amount_configuration: Beloppskonfiguration
+ amount_configuration_help: 'JSON, t.ex. {"points": 1, "per_amount": 100} för per belopp, {"points": 50} för fast, {"factor": 2} för multiplikator, {"expression": "floor(basis / 100)"} för uttryck'
+ amount_expression: Beloppsuttryck
+ amount_expression_help: Används när beloppstypen är "Uttryck"; variabeln "basis" innehåller den bas som regeln gör anspråk på, i minsta enheter
+ amount_requires_order_trigger: Typerna per belopp och multiplikator kräver en orderbas och är endast tillgängliga för den inbyggda ordertriggern.
+ amount_type: Beloppstyp
+ condition_configuration: Villkorskonfiguration
+ condition_configuration_help: 'JSON, t.ex. {"amount": 5000} eller {"taxons": ["t_shirts"]}'
+ condition_expression: Villkorsuttryck
+ condition_expression_help: Används när villkorstypen är "Uttryck"; villkoret uppfylls när uttrycket utvärderas som sant
+ condition_type: Villkorstyp
+ expression_required: Uttrycksläget kräver ett uttryck.
+ multiplier_is_order_scoped: Multiplikatorregler gäller endast hela ordern; använd uttrycksläget för logik på radnivå.
+ scope_requires_order_trigger: Taxon- och produktavgränsningar kräver orderrader och är endast tillgängliga för den inbyggda ordertriggern.
+ conditions: Villkor
+ conditions_match: Matchning av villkor
+ conditions_match_all: Alla villkor måste uppfyllas
+ conditions_match_any: Minst ett villkor måste uppfyllas
+ dry_run: Testkörning
+ dry_run_help: Utvärdera mot verklig trafik och logga till granskningslistan vad som skulle ha tilldelats, utan att skriva några poäng
+ ends_at: Slutar
+ scope: Avgränsning
+ scope_configuration: Avgränsningskonfiguration
+ scope_configuration_help: 'JSON, t.ex. {"taxons": ["t_shirts"]} eller {"products": ["MUG"]}; lämna tomt för hela ordern'
+ scope_help: Produkt- eller taxonavgränsade regler gör exklusivt anspråk på sina matchande artiklar; orderavgränsade regler tjänar in på resten
+ scope_order: Hela ordern
+ scope_product: Specifika produkter
+ scope_taxon: Specifika taxoner
+ stackable: Kombinerbar
+ stackable_help: Kombinerbara regler som konkurrerar om samma bas gäller alla och summeras; en icke-kombinerbar regel gäller ensam (högsta prioritet vinner)
+ starts_at: Börjar
+ trigger: Trigger
+ window_timezone_help: Anges och utvärderas i applikationens konfigurerade tidszon
+ earning_rule.amount:
+ expression: Uttryck
+ fixed: Fasta poäng
+ multiplier: Multiplikator
+ per_amount: Poäng per belopp
+ earning_rule.condition:
+ cart_contains_taxon: Varukorgen innehåller taxon
+ customer_group: Kundgrupp
+ date_window: Datumintervall
+ day_of_week: Veckodag
+ expression: Uttryck
+ nth_order: 'N:te ordern'
+ order_total_at_least: Ordersumma minst
+ invalid_json: Detta värde är inte giltig JSON.
+ program:
+ award_moment_order_fulfilled: När ordern är slutförd
+ award_moment_payment_paid: När betalningen är helt betald
+ award_order_points_at: Tidpunkt för tilldelning av orderpoäng
+ award_order_points_at_help: Det enda tillfället i orderns livscykel då poäng tilldelas — ett per program, aldrig per regel
+ base_currency_help: Belopp anges i minsta enheter av kanalens basvaluta
+ clawback_policy: Återtagspolicy
+ clawback_policy_allow_negative: Tillåt negativt saldo (återvinns från framtida intjäning)
+ clawback_policy_clamp_to_zero: Begränsa saldot till noll
+ earning_basis: Intjäningsbas
+ earning_basis_help: Vad som räknas in i intjäningen; själva intjäningstakten definieras av intjäningsregler
+ earning_basis_items_total: Artikelsumma (exkl. frakt)
+ earning_basis_order_total: Ordersumma (inkl. frakt)
+ include_taxes: Inkludera moms i basen
+ max_redeem_percent_of_order: Max. inlösbar procent av ordern
+ max_redeem_percent_of_order_help: Den andel av orderns artikelsumma som kan täckas av poäng (100 tillåter helt gratis ordrar)
+ min_redeem_points: Minsta antal poäng per inlösen
+ points_expiry_days: Poäng förfaller efter (dagar)
+ points_expiry_days_help: Lämna tomt så förfaller poängen aldrig. Observera att förfall av lagrat värde kan omfattas av konsumenträttsliga begränsningar i vissa jurisdiktioner
+ redemption_conversion_amount: '... är värda detta belopp (minsta enheter)'
+ redemption_conversion_help: 'Inlösenkursen: så här många poäng...'
+ redemption_conversion_points: Poängenhet
+ retroactive_guest_points: Retroaktiva gästpoäng
+ retroactive_guest_points_help: Tilldela poäng för gästordrar lagda före registreringen när gästen registrerar sig (ingen retroaktiv tilldelning för tidigare registreringar)
+ rounding: Avrundning
+ show_earnable_in_cart: Visa intjäningstipset i varukorgen
+ show_earnable_on_product: Visa intjäningstipset på produktsidor
+ tier_downgrade_grace_days: Frist vid nivånedflyttning (dagar)
+ tier_downgrade_grace_days_help: Hur länge ett konto behåller sin nivå efter att ha hamnat under tröskeln; 0 flyttar ned vid nästa nattliga utvärdering
+ tier_evaluation_window: Utvärderingsfönster för nivåer
+ tier_window_calendar_year: Kalenderår
+ tier_window_lifetime: Livstid
+ tier_window_rolling_12_months: Rullande 12 månader
+ rounding_ceil: Uppåt
+ rounding_floor: Nedåt
+ rounding_round: Avrunda
+ promotion_rule:
+ customer_loyalty_tier: Kundens lojalitetsnivå är minst
+ minimum_tier: Lägsta nivå
+ tier:
+ benefits: Förmåner
+ benefits_description: Beskrivning av förmånerna
+ color: Badgefärg
+ earning_multiplier: Intjäningsmultiplikator
+ earning_multiplier_help: Tillämpas på all poängintjäning medan kontot är på denna nivå (1 = ingen ändring)
+ position_help: Högre positioner är högre nivåer; den högsta positionen är toppnivån
+ qualification_basis: Kvalificeringsgrund
+ threshold: Tröskel
+ threshold_help: Enheten beror på grunden (poäng, minsta valutaenheter eller order)
+ tier_basis:
+ amount_spent: Spenderat belopp
+ orders_count: Lagda order
+ points_earned: Intjänade poäng
+ unit:
+ currency: minsta valutaenheter
+ orders: order
+ points: poäng
+ trigger:
+ customer_birthday: Kundens födelsedag
+ customer_registered: Kund registrerad
+ order_eligible: Order (inbyggd)
+ product_review_approved: Produktrecension godkänd
+ ui:
+ account: Lojalitetskonto
+ account_inactive: Ditt lojalitetskonto är för närvarande inaktivt — ditt saldo är bevarat, men intjäning och inlösen är pausade.
+ accounts: Lojalitetskonton
+ accounts_card_description: Bläddra bland konton, granska huvudböcker, justera poäng
+ adjust: Justera
+ anonymized: Anonymiserad
+ balance: Ditt poängsaldo
+ balance_label: Saldo
+ change_on_cart: Ändra i varukorgen
+ consumptions: Förbrukad av
+ dashboard_subtitle: Poäng, intjäningsregler och programinställningar
+ derived_balance: Summa i huvudboken
+ dry_run: Testkörning
+ dry_run_results: Testkörningsresultat
+ dry_run_results_card_description: Vad testkörningsregler skulle ha tilldelat
+ earn_hint_cart: 'Denna order ger ~%points% poäng.'
+ earn_hint_product: 'Du tjänar %points% poäng när du köper denna produkt.'
+ earning_rate_hint: 'Intjäningstakten definieras av intjäningsregler: en basregel för hela ordern med typen per belopp är butikens standardtakt.'
+ earning_rules: Intjäningsregler
+ earning_rules_card_description: Triggrar, villkor och belopp
+ edit_earning_rule: Redigera intjäningsregel
+ edit_program: Redigera program
+ new_earning_rule: Ny intjäningsregel
+ description: Beskrivning
+ expired: Förfallen
+ expires: 'förfaller %date%'
+ expires_at: Förfaller
+ expiring_soon: '%points% poäng förfaller före %date%.'
+ history: Transaktionshistorik
+ history_description:
+ clawback: 'Poäng återtagna för en annullerad eller återbetald order'
+ earn_action: 'Poäng intjänade'
+ earn_order: 'Intjänade på order %order%'
+ earn_referral: 'Värvningsbelöning'
+ expire: 'Poäng förfallna'
+ manual_credit: 'Justering: %reason%'
+ manual_debit: 'Justering: %reason%'
+ redeem: 'Inlösta på order %order%'
+ redeem_rollback: 'Poäng återställda från en annullerad order'
+ history_empty: Inga transaktioner ännu — poäng du tjänar in och använder visas här.
+ my_loyalty: Mitt lojalitetsprogram
+ running_balance: Saldo
+ inspect: Granska
+ invariants_hold: Alla huvudbokens invarianter håller.
+ invariants_violated: Huvudbokens invarianter är brutna — utred innan manuella korrigeringar görs.
+ ledger: Huvudbok
+ lifetime_earned: Intjänat totalt
+ lot: Lot
+ lots: Lotter (härledda via återuppspelning)
+ loyalty: Lojalitet
+ manual_adjustment: Manuell justering
+ manual_reason:
+ correction: Korrigering
+ goodwill: Goodwill
+ other: Övrigt
+ promotion: Kampanj
+ never: aldrig
+ note: Anteckning
+ points: Poäng
+ points_amount: '%points% poäng'
+ points_applied: '%points% poäng tillämpade'
+ points_redemption: Poänginlösen
+ program: Program
+ program_card_description: Konvertering, förfall och policyer per kanal
+ reason: Orsak
+ refer_a_friend: Värva en vän
+ referee: Värvad
+ referral_explainer: Dela din länk — när en vän lägger sin första order tjänar ni båda poäng.
+ referral_override: Godkänn ändå
+ referral_stats: 'Du har %count% belönade värvning(ar) som har gett %points% poäng.'
+ referral_status:
+ expired: Förfallen
+ pending: Väntande
+ qualified: Kvalificerad
+ rejected: Avvisad
+ rewarded: Belönad
+ referrals: Värvningar
+ referrals_card_description: Värvningsstatusar och åsidosättning av bedrägerikontroller
+ referrer: Värvare
+ redemption_clamped: 'Din begäran om %requested% poäng tillämpas endast delvis på denna order; den ökar automatiskt igen när ordern tillåter det.'
+ remaining: Återstående
+ remove_redemption: Ta bort
+ rule: Regel
+ rule_tester: Regeltestare
+ rule_tester_card_description: Utvärdera regler mot en nyligen lagd order, eventuellt vid ett annat datum
+ scope: Avgränsning
+ share: Dela
+ copied: Kopierad!
+ stat_accounts: Konton
+ stat_active_accounts_90_days: Aktiva konton (90 dagar)
+ stat_liability: Utestående poängskuld
+ stat_redemption_rate_90_days: Inlösengrad (90 dagar)
+ stat_earned_30_days: Poäng intjänade (30 dagar)
+ stat_redeemed_30_days: Poäng inlösta (30 dagar)
+ tester_applied: Tillämpad
+ tester_claimed_basis: Bas i anspråk
+ tester_claimed_by: I anspråk av
+ tester_evaluate: Utvärdera
+ tester_evaluate_at: Utvärdera som om det vore
+ tester_evaluate_at_help: Förhandsgranska schemalagda regler innan deras fönster öppnas
+ tester_failed_conditions: Ej uppfyllda villkor
+ tester_final_award: 'Slutlig tilldelning: %points% poäng'
+ tester_invalid_date: Utvärderingsdatumet är ogiltigt.
+ tester_item_claims: Anspråk per artikel
+ tester_matched: Matchad
+ tester_order_has_no_customer: Ordern har ingen kund, så inga regler kan utvärderas.
+ tester_order_not_found: Ingen order med det numret hittades.
+ tester_order_number: Ordernummer
+ tester_rules: Regelutvärderingar
+ tier: Nivå
+ tier_progress: '%metric% / %threshold% till %tier%'
+ tier_top: Du har nått den högsta nivån — tack för att du är en av våra allra bästa kunder!
+ tiers: Nivåer
+ tiers_card_description: Nivåer, trösklar, multiplikatorer och förmåner
+ trigger: Trigger
+ use_max: Använd max
+ your_code: Din kod
+ use_your_points: Använd dina poäng
diff --git a/src/Resources/translations/messages.uk.yaml b/src/Resources/translations/messages.uk.yaml
new file mode 100644
index 0000000..aa84df8
--- /dev/null
+++ b/src/Resources/translations/messages.uk.yaml
@@ -0,0 +1,233 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ form:
+ earning_rule:
+ add_condition: Додати умову
+ amount_configuration: Налаштування суми
+ amount_configuration_help: 'JSON, напр. {"points": 1, "per_amount": 100} для балів за суму, {"points": 50} для фіксованої кількості, {"factor": 2} для множника, {"expression": "floor(basis / 100)"} для виразу'
+ amount_expression: Вираз суми
+ amount_expression_help: Використовується, коли тип суми — «Вираз»; змінна "basis" містить базу, заявлену правилом, у мінімальних грошових одиницях
+ amount_requires_order_trigger: Типи «бали за суму» та «множник» потребують бази замовлення й доступні лише для вбудованого тригера замовлення.
+ amount_type: Тип суми
+ condition_configuration: Налаштування умови
+ condition_configuration_help: 'JSON, напр. {"amount": 5000} або {"taxons": ["t_shirts"]}'
+ condition_expression: Вираз умови
+ condition_expression_help: Використовується, коли тип умови — «Вираз»; умова виконується, коли вираз істинний
+ condition_type: Тип умови
+ expression_required: Режим виразу потребує виразу.
+ multiplier_is_order_scoped: Правила з множником діють лише на рівні замовлення; для логіки на рівні позицій використовуйте режим виразу.
+ scope_requires_order_trigger: Області дії за таксоном і товаром потребують позицій замовлення й доступні лише для вбудованого тригера замовлення.
+ conditions: Умови
+ conditions_match: Відповідність умов
+ conditions_match_all: Мають виконуватися всі умови
+ conditions_match_any: Має виконуватися принаймні одна умова
+ dry_run: Тестовий запуск
+ dry_run_help: Оцінювати на реальному трафіку та записувати до списку аудиту, що було б нараховано, без запису жодних балів
+ ends_at: Завершується
+ scope: Область дії
+ scope_configuration: Налаштування області дії
+ scope_configuration_help: 'JSON, напр. {"taxons": ["t_shirts"]} або {"products": ["MUG"]}; залиште порожнім для області замовлення'
+ scope_help: Правила з областю дії за товаром або таксоном ексклюзивно забирають відповідні позиції; правила на рівні замовлення нараховують бали на решту
+ scope_order: Усе замовлення
+ scope_product: Певні товари
+ scope_taxon: Певні таксони
+ stackable: Сумісне з іншими
+ stackable_help: Сумісні правила, що претендують на ту саму базу, застосовуються всі разом і сумуються; несумісне правило застосовується самостійно (перемагає найвищий пріоритет)
+ starts_at: Починається
+ trigger: Тригер
+ window_timezone_help: Вводиться та оцінюється в часовому поясі, налаштованому в застосунку
+ earning_rule.amount:
+ expression: Вираз
+ fixed: Фіксовані бали
+ multiplier: Множник
+ per_amount: Бали за суму
+ earning_rule.condition:
+ cart_contains_taxon: Кошик містить таксон
+ customer_group: Група клієнтів
+ date_window: Проміжок дат
+ day_of_week: День тижня
+ expression: Вираз
+ nth_order: N-не замовлення
+ order_total_at_least: Сума замовлення щонайменше
+ invalid_json: Це значення не є коректним JSON.
+ program:
+ award_moment_order_fulfilled: Коли замовлення виконано
+ award_moment_payment_paid: Коли оплату здійснено повністю
+ award_order_points_at: Нараховувати бали за замовлення в момент
+ award_order_points_at_help: Єдиний момент життєвого циклу замовлення, коли нараховуються бали — один на програму, ніколи не на правило
+ base_currency_help: Суми вказуються в мінімальних одиницях базової валюти каналу
+ clawback_policy: Політика повернення балів
+ clawback_policy_allow_negative: Дозволити відʼємний баланс (компенсується з майбутніх нарахувань)
+ clawback_policy_clamp_to_zero: Обмежити баланс нулем
+ earning_basis: База нарахування
+ earning_basis_help: Що враховується під час нарахування; саму ставку нарахування визначають правила нарахування
+ earning_basis_items_total: Сума позицій (без доставки)
+ earning_basis_order_total: Сума замовлення (з доставкою)
+ include_taxes: Включати податки до бази
+ max_redeem_percent_of_order: Макс. відсоток замовлення для списання
+ max_redeem_percent_of_order_help: Частка суми позицій замовлення, яку можна покрити балами (100 дозволяє повністю безкоштовні замовлення)
+ min_redeem_points: Мінімум балів на одне списання
+ points_expiry_days: Бали спливають через (днів)
+ points_expiry_days_help: Залиште порожнім, щоб бали ніколи не спливали. Зауважте, що в деяких юрисдикціях сплив накопиченої вартості підпадає під обмеження законодавства про захист споживачів
+ redemption_conversion_amount: '... варті цієї суми (мінімальні одиниці)'
+ redemption_conversion_help: 'Конвертація при списанні: така кількість балів...'
+ redemption_conversion_points: Одиниця балів
+ retroactive_guest_points: Ретроактивні бали гостей
+ retroactive_guest_points_help: Нараховувати бали за гостьові замовлення, зроблені до реєстрації, коли гість реєструється (без донарахування для раніших реєстрацій)
+ rounding: Округлення
+ show_earnable_in_cart: Показувати підказку про бали в кошику
+ show_earnable_on_product: Показувати підказку про бали на сторінках товарів
+ tier_downgrade_grace_days: Пільговий період перед пониженням рівня (днів)
+ tier_downgrade_grace_days_help: Як довго рахунок зберігає свій рівень після падіння нижче порога; 0 — пониження під час наступної нічної переоцінки
+ tier_evaluation_window: Вікно оцінювання рівнів
+ tier_window_calendar_year: Календарний рік
+ tier_window_lifetime: За весь час
+ tier_window_rolling_12_months: Ковзні 12 місяців
+ rounding_ceil: Догори
+ rounding_floor: Донизу
+ rounding_round: Математичне
+ promotion_rule:
+ customer_loyalty_tier: Рівень лояльності клієнта не нижчий за
+ minimum_tier: Мінімальний рівень
+ tier:
+ benefits: Переваги
+ benefits_description: Опис переваг
+ color: Колір значка
+ earning_multiplier: Множник нарахування
+ earning_multiplier_help: Застосовується до кожного нарахування балів, поки рахунок перебуває на цьому рівні (1 = без змін)
+ position_help: Вищі позиції — це вищі рівні; найвища позиція є найвищим рівнем
+ qualification_basis: База кваліфікації
+ threshold: Поріг
+ threshold_help: Одиниця залежить від бази (бали, мінімальні одиниці валюти або замовлення)
+ tier_basis:
+ amount_spent: Витрачена сума
+ orders_count: Розміщені замовлення
+ points_earned: Нараховані бали
+ unit:
+ currency: мінімальні одиниці валюти
+ orders: замовлення
+ points: бали
+ trigger:
+ customer_birthday: День народження клієнта
+ customer_registered: Клієнт зареєструвався
+ order_eligible: Замовлення (вбудований)
+ product_review_approved: Відгук про товар схвалено
+ ui:
+ account: Рахунок лояльності
+ account_inactive: Ваш рахунок лояльності наразі неактивний — баланс збережено, але нарахування та списання балів призупинені.
+ accounts: Рахунки лояльності
+ accounts_card_description: Перегляд рахунків, аналіз журналів, коригування балів
+ adjust: Коригувати
+ anonymized: Анонімізовано
+ balance: Ваш баланс балів
+ balance_label: Баланс
+ change_on_cart: Змінити в кошику
+ consumptions: Використано
+ dashboard_subtitle: Бали, правила нарахування та налаштування програми
+ derived_balance: Сума за журналом
+ dry_run: Тестовий запуск
+ dry_run_results: Результати тестового запуску
+ dry_run_results_card_description: Що нарахували б правила в тестовому режимі
+ earn_hint_cart: 'За це замовлення нараховується ~%points% балів.'
+ earn_hint_product: 'Купуючи цей товар, ви отримаєте %points% балів.'
+ earning_rate_hint: 'Ставку нарахування визначають правила нарахування: базове правило на рівні замовлення з типом «бали за суму» — це стандартна ставка магазину.'
+ earning_rules: Правила нарахування
+ earning_rules_card_description: Тригери, умови та суми
+ edit_earning_rule: Редагувати правило нарахування
+ edit_program: Редагувати програму
+ new_earning_rule: Нове правило нарахування
+ description: Опис
+ expired: Сплив
+ expires: 'спливає %date%'
+ expires_at: Спливає
+ expiring_soon: '%points% балів спливає до %date%.'
+ history: Історія транзакцій
+ history_description:
+ clawback: 'Бали, повернуті за скасоване або відшкодоване замовлення'
+ earn_action: 'Нараховані бали'
+ earn_order: 'Нараховано за замовлення %order%'
+ earn_referral: 'Винагорода за рекомендацію'
+ expire: 'Бали спливли'
+ manual_credit: 'Коригування: %reason%'
+ manual_debit: 'Коригування: %reason%'
+ redeem: 'Списано за замовлення %order%'
+ redeem_rollback: 'Бали відновлено зі скасованого замовлення'
+ history_empty: Транзакцій поки немає — бали, які ви заробите й витратите, зʼявляться тут.
+ my_loyalty: Моя лояльність
+ running_balance: Баланс
+ inspect: Переглянути
+ invariants_hold: Усі інваріанти журналу виконуються.
+ invariants_violated: Інваріанти журналу порушено — розберіться, перш ніж робити ручні виправлення.
+ ledger: Журнал
+ lifetime_earned: Зароблено за весь час
+ lot: Партія
+ lots: Партії (відтворені з журналу)
+ loyalty: Лояльність
+ manual_adjustment: Ручне коригування
+ manual_reason:
+ correction: Виправлення
+ goodwill: Жест доброї волі
+ other: Інше
+ promotion: Акція
+ never: ніколи
+ note: Примітка
+ points: Бали
+ points_amount: '%points% балів'
+ points_applied: 'Застосовано %points% балів'
+ points_redemption: Списання балів
+ program: Програма
+ program_card_description: Конвертація, сплив і політики для кожного каналу
+ reason: Причина
+ refer_a_friend: Порекомендуйте другові
+ referee: Запрошений
+ referral_explainer: Поділіться своїм посиланням — коли друг зробить своє перше замовлення, ви обидва отримаєте бали.
+ referral_override: Схвалити попри все
+ referral_stats: 'У вас %count% винагороджених рекомендацій, за які нараховано %points% балів.'
+ referral_status:
+ expired: Прострочена
+ pending: Очікує
+ qualified: Кваліфікована
+ rejected: Відхилена
+ rewarded: Винагороджена
+ referrals: Рекомендації
+ referrals_card_description: Статуси рекомендацій і ручні схвалення після перевірок на шахрайство
+ referrer: Рекомендувач
+ redemption_clamped: 'Ваш запит на %requested% балів застосовано до цього замовлення лише частково; він автоматично збільшиться, щойно замовлення це дозволить.'
+ remaining: Залишок
+ remove_redemption: Прибрати
+ rule: Правило
+ rule_tester: Тестування правил
+ rule_tester_card_description: Оцініть правила на нещодавньому замовленні, за бажанням на іншу дату
+ scope: Область дії
+ share: Поділитися
+ copied: Скопійовано!
+ stat_accounts: Рахунки
+ stat_active_accounts_90_days: Активні рахунки (90 днів)
+ stat_liability: 'Непогашене зобов''язання за балами'
+ stat_redemption_rate_90_days: Частка списання (90 днів)
+ stat_earned_30_days: Нараховано балів (30 днів)
+ stat_redeemed_30_days: Списано балів (30 днів)
+ tester_applied: Застосовано
+ tester_claimed_basis: Заявлена база
+ tester_claimed_by: Заявлено правилом
+ tester_evaluate: Оцінити
+ tester_evaluate_at: Оцінити так, ніби зараз
+ tester_evaluate_at_help: Попередній перегляд запланованих правил до відкриття їхнього вікна
+ tester_failed_conditions: Невиконані умови
+ tester_final_award: 'Підсумкове нарахування: %points% балів'
+ tester_invalid_date: Дата оцінювання некоректна.
+ tester_item_claims: Заявки за позиціями
+ tester_matched: Збіглося
+ tester_order_has_no_customer: У замовлення немає клієнта, тому жодне правило не можна оцінити.
+ tester_order_not_found: Замовлення з таким номером не знайдено.
+ tester_order_number: Номер замовлення
+ tester_rules: Оцінювання правил
+ tier: Рівень
+ tier_progress: '%metric% / %threshold% до %tier%'
+ tier_top: Ви досягли найвищого рівня — дякуємо, що ви один із наших найкращих клієнтів!
+ tiers: Рівні
+ tiers_card_description: Рівні, пороги, множники та переваги
+ trigger: Тригер
+ use_max: Використати максимум
+ your_code: Ваш код
+ use_your_points: Використайте свої бали
diff --git a/src/Resources/translations/validators.cs.yaml b/src/Resources/translations/validators.cs.yaml
new file mode 100644
index 0000000..75a0497
--- /dev/null
+++ b/src/Resources/translations/validators.cs.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Váš věrnostní účet je neaktivní, takže uplatněné body nelze využít. Odeberte je prosím z objednávky.
+ insufficient_balance: Váš zůstatek bodů již nepokrývá uplatněné body. Upravte je prosím v košíku.
diff --git a/src/Resources/translations/validators.da.yaml b/src/Resources/translations/validators.da.yaml
new file mode 100644
index 0000000..f5189b3
--- /dev/null
+++ b/src/Resources/translations/validators.da.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Din loyalitetskonto er inaktiv, så de anvendte point kan ikke indløses. Fjern dem venligst fra ordren.
+ insufficient_balance: Din pointsaldo dækker ikke længere den anvendte indløsning. Justér den venligst i kurven.
diff --git a/src/Resources/translations/validators.de.yaml b/src/Resources/translations/validators.de.yaml
new file mode 100644
index 0000000..e4435d6
--- /dev/null
+++ b/src/Resources/translations/validators.de.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Ihr Treuekonto ist inaktiv, daher können die angewendeten Punkte nicht eingelöst werden. Bitte entfernen Sie sie aus der Bestellung.
+ insufficient_balance: Ihr Punktestand deckt die angewendete Einlösung nicht mehr. Bitte passen Sie sie im Warenkorb an.
diff --git a/src/Resources/translations/validators.en.yaml b/src/Resources/translations/validators.en.yaml
new file mode 100644
index 0000000..3eac165
--- /dev/null
+++ b/src/Resources/translations/validators.en.yaml
@@ -0,0 +1,4 @@
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Your loyalty account is inactive, so the applied points cannot be redeemed. Please remove them from the order.
+ insufficient_balance: Your points balance no longer covers the applied redemption. Please adjust it on the cart.
diff --git a/src/Resources/translations/validators.es.yaml b/src/Resources/translations/validators.es.yaml
new file mode 100644
index 0000000..c138a21
--- /dev/null
+++ b/src/Resources/translations/validators.es.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Tu cuenta de fidelidad está inactiva, por lo que los puntos aplicados no se pueden canjear. Quítalos del pedido.
+ insufficient_balance: Tu saldo de puntos ya no cubre el canje aplicado. Ajústalo en el carrito.
diff --git a/src/Resources/translations/validators.fi.yaml b/src/Resources/translations/validators.fi.yaml
new file mode 100644
index 0000000..3df43be
--- /dev/null
+++ b/src/Resources/translations/validators.fi.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Kanta-asiakastilisi ei ole käytössä, joten käytettyjä pisteitä ei voida lunastaa. Poista ne tilauksesta.
+ insufficient_balance: Pistesaldosi ei enää riitä käytettyyn lunastukseen. Muuta sitä ostoskorissa.
diff --git a/src/Resources/translations/validators.fr.yaml b/src/Resources/translations/validators.fr.yaml
new file mode 100644
index 0000000..c2df714
--- /dev/null
+++ b/src/Resources/translations/validators.fr.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Votre compte de fidélité est inactif, les points appliqués ne peuvent donc pas être utilisés. Veuillez les retirer de la commande.
+ insufficient_balance: 'Votre solde de points ne couvre plus l''utilisation appliquée. Veuillez l''ajuster sur le panier.'
diff --git a/src/Resources/translations/validators.hu.yaml b/src/Resources/translations/validators.hu.yaml
new file mode 100644
index 0000000..aa81661
--- /dev/null
+++ b/src/Resources/translations/validators.hu.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Hűségfiókja inaktív, ezért a felhasznált pontok nem válthatók be. Kérjük, távolítsa el őket a rendelésből.
+ insufficient_balance: Pontegyenlege már nem fedezi a felhasznált beváltást. Kérjük, módosítsa a kosárban.
diff --git a/src/Resources/translations/validators.it.yaml b/src/Resources/translations/validators.it.yaml
new file mode 100644
index 0000000..e853894
--- /dev/null
+++ b/src/Resources/translations/validators.it.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: 'Il tuo account fedeltà è inattivo, quindi i punti applicati non possono essere riscattati. Rimuovili dall''ordine.'
+ insufficient_balance: Il tuo saldo punti non copre più il riscatto applicato. Modificalo nel carrello.
diff --git a/src/Resources/translations/validators.nl.yaml b/src/Resources/translations/validators.nl.yaml
new file mode 100644
index 0000000..967f4e9
--- /dev/null
+++ b/src/Resources/translations/validators.nl.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Je loyaliteitsaccount is inactief, dus de toegepaste punten kunnen niet worden ingewisseld. Verwijder ze van de bestelling.
+ insufficient_balance: Je puntensaldo dekt de toegepaste inwisseling niet meer. Pas dit aan in de winkelwagen.
diff --git a/src/Resources/translations/validators.no.yaml b/src/Resources/translations/validators.no.yaml
new file mode 100644
index 0000000..38d3541
--- /dev/null
+++ b/src/Resources/translations/validators.no.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Lojalitetskontoen din er inaktiv, så de anvendte pointene kan ikke innløses. Fjern dem fra ordren.
+ insufficient_balance: Pointsaldoen din dekker ikke lenger den anvendte innløsningen. Juster den i handlekurven.
diff --git a/src/Resources/translations/validators.pl.yaml b/src/Resources/translations/validators.pl.yaml
new file mode 100644
index 0000000..08319eb
--- /dev/null
+++ b/src/Resources/translations/validators.pl.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Twoje konto lojalnościowe jest nieaktywne, więc zastosowanych punktów nie można wymienić. Usuń je z zamówienia.
+ insufficient_balance: Twoje saldo punktów nie pokrywa już zastosowanej wymiany. Skoryguj ją w koszyku.
diff --git a/src/Resources/translations/validators.pt.yaml b/src/Resources/translations/validators.pt.yaml
new file mode 100644
index 0000000..4fabeee
--- /dev/null
+++ b/src/Resources/translations/validators.pt.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: A sua conta de fidelidade está inativa, pelo que os pontos aplicados não podem ser resgatados. Remova-os da encomenda.
+ insufficient_balance: O seu saldo de pontos já não cobre o resgate aplicado. Ajuste-o no carrinho.
diff --git a/src/Resources/translations/validators.ro.yaml b/src/Resources/translations/validators.ro.yaml
new file mode 100644
index 0000000..09e65f2
--- /dev/null
+++ b/src/Resources/translations/validators.ro.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Contul dumneavoastră de fidelitate este inactiv, așa că punctele aplicate nu pot fi utilizate. Vă rugăm să le eliminați din comandă.
+ insufficient_balance: Soldul dumneavoastră de puncte nu mai acoperă punctele aplicate. Vă rugăm să îl ajustați în coș.
diff --git a/src/Resources/translations/validators.sv.yaml b/src/Resources/translations/validators.sv.yaml
new file mode 100644
index 0000000..30e20bc
--- /dev/null
+++ b/src/Resources/translations/validators.sv.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Ditt lojalitetskonto är inaktivt, så de tillämpade poängen kan inte lösas in. Ta bort dem från ordern.
+ insufficient_balance: Ditt poängsaldo täcker inte längre den tillämpade inlösen. Justera den i varukorgen.
diff --git a/src/Resources/translations/validators.uk.yaml b/src/Resources/translations/validators.uk.yaml
new file mode 100644
index 0000000..0f406ad
--- /dev/null
+++ b/src/Resources/translations/validators.uk.yaml
@@ -0,0 +1,5 @@
+# Machine-translated from English — pending native review.
+setono_sylius_loyalty:
+ checkout:
+ account_disabled: Ваш рахунок лояльності неактивний, тому застосовані бали неможливо списати. Будь ласка, приберіть їх із замовлення.
+ insufficient_balance: Вашого балансу балів більше не вистачає на застосоване списання. Будь ласка, скоригуйте його в кошику.
diff --git a/src/Resources/views/admin/account/inspect.html.twig b/src/Resources/views/admin/account/inspect.html.twig
new file mode 100644
index 0000000..c477565
--- /dev/null
+++ b/src/Resources/views/admin/account/inspect.html.twig
@@ -0,0 +1,142 @@
+{% extends '@SyliusAdmin/layout.html.twig' %}
+
+{% block title %}{{ 'setono_sylius_loyalty.ui.account'|trans }} #{{ account.id }} {{ parent() }}{% endblock %}
+
+{% block content %}
+
+
+
+ {{ account.customer ? account.customer.email : 'setono_sylius_loyalty.ui.anonymized'|trans }}
+
+ {{ 'sylius.ui.channel'|trans }}: {{ account.channel ? account.channel.code : '—' }}
+ {% if account.tier %}
+
+ {{ account.tier.name }}
+
+ {% endif %}
+ {% if not account.enabled %}
+ {{ 'sylius.ui.disabled'|trans }}
+ {% endif %}
+
+
+
+
+
+
+ {{ account.balance }}
+ {{ 'setono_sylius_loyalty.ui.balance_label'|trans }}
+
+
+ {{ account.lifetimeEarned }}
+ {{ 'setono_sylius_loyalty.ui.lifetime_earned'|trans }}
+
+
+ {{ inspection.replay.balance }}
+ {{ 'setono_sylius_loyalty.ui.derived_balance'|trans }}
+
+
+
+ {% if inspection.healthy %}
+
+ {% else %}
+
+ {% endif %}
+ {% for warning in inspection.warnings %}
+
+ {% endfor %}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.manual_adjustment'|trans }}
+
+
+
+ {{ 'sylius.ui.actions'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.lots'|trans }}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.lot'|trans }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+ {{ 'setono_sylius_loyalty.ui.expires_at'|trans }}
+ {{ 'setono_sylius_loyalty.ui.remaining'|trans }}
+ {{ 'setono_sylius_loyalty.ui.consumptions'|trans }}
+
+
+
+ {% for lotState in inspection.replay.lots %}
+
+ #{{ lotState.lot.id }}
+ {{ lotState.lot.points }}
+
+ {{ lotState.lot.expiresAt ? lotState.lot.expiresAt|date('Y-m-d') : 'setono_sylius_loyalty.ui.never'|trans }}
+ {% if lotState.closedByExpiration %}{{ 'setono_sylius_loyalty.ui.expired'|trans }}{% endif %}
+
+ {{ lotState.remaining }}
+
+ {% for consumption in lotState.consumptions %}
+ #{{ consumption.debit.id }}: −{{ consumption.points }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.ledger'|trans }}
+
+
+
+ {{ 'sylius.ui.id'|trans }}
+ {{ 'sylius.ui.type'|trans }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+ {{ 'sylius.ui.date'|trans }}
+
+
+
+ {% for transaction in transactions %}
+
+ #{{ transaction.id }}
+ {{ setono_sylius_loyalty_transaction_type(transaction) }}
+ {{ transaction.points }}
+ {{ transaction.occurredAt|date('Y-m-d H:i:s') }}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/Resources/views/admin/customer/_loyalty.html.twig b/src/Resources/views/admin/customer/_loyalty.html.twig
new file mode 100644
index 0000000..b4cec79
--- /dev/null
+++ b/src/Resources/views/admin/customer/_loyalty.html.twig
@@ -0,0 +1,49 @@
+{% set accounts = setono_sylius_loyalty_accounts(customer) %}
+
+{% if accounts is not empty %}
+
+ {{ 'setono_sylius_loyalty.ui.loyalty'|trans }}
+
+ {% for account in accounts %}
+
+
+
+ {{ account.balance }}
+
+ {{ 'setono_sylius_loyalty.ui.balance_label'|trans }}
+ ({{ account.channel ? account.channel.code : '—' }})
+ {% if not account.enabled %}
+ {{ 'sylius.ui.disabled'|trans }}
+ {% endif %}
+
+
+
+
+
+
+
+
+ {{ 'sylius.ui.type'|trans }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+ {{ 'sylius.ui.date'|trans }}
+
+
+
+ {% for transaction in setono_sylius_loyalty_latest_transactions(account, 25) %}
+
+ {{ setono_sylius_loyalty_transaction_type(transaction) }}
+ {{ transaction.points }}
+ {{ transaction.occurredAt|date('Y-m-d H:i') }}
+
+ {% endfor %}
+
+
+
+
+ {% endfor %}
+
+{% endif %}
diff --git a/src/Resources/views/admin/dashboard/index.html.twig b/src/Resources/views/admin/dashboard/index.html.twig
new file mode 100644
index 0000000..c37f5c2
--- /dev/null
+++ b/src/Resources/views/admin/dashboard/index.html.twig
@@ -0,0 +1,93 @@
+{% extends '@SyliusAdmin/layout.html.twig' %}
+
+{% block title %}{{ 'setono_sylius_loyalty.ui.loyalty'|trans }} {{ parent() }}{% endblock %}
+
+{% block content %}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.loyalty'|trans }}
+ {{ 'setono_sylius_loyalty.ui.dashboard_subtitle'|trans }}
+
+
+
+
+
+ {{ stats.accounts }}
+ {{ 'setono_sylius_loyalty.ui.stat_accounts'|trans }}
+
+
+ {{ stats.earnedLast30Days }}
+ {{ 'setono_sylius_loyalty.ui.stat_earned_30_days'|trans }}
+
+
+ {{ stats.redeemedLast30Days }}
+ {{ 'setono_sylius_loyalty.ui.stat_redeemed_30_days'|trans }}
+
+
+
+
+
+ {{ stats.liabilityPoints ?? '—' }}
+
+ {{ 'setono_sylius_loyalty.ui.stat_liability'|trans }}
+ {% if stats.liabilityCalculatedAt %}
+ ({{ stats.liabilityCalculatedAt|date('Y-m-d H:i') }})
+ {% endif %}
+
+
+
+ {{ stats.redemptionRate90Days is not null ? stats.redemptionRate90Days ~ '%' : '—' }}
+ {{ 'setono_sylius_loyalty.ui.stat_redemption_rate_90_days'|trans }}
+
+
+ {{ stats.activeAccounts90Days }}
+ {{ 'setono_sylius_loyalty.ui.stat_active_accounts_90_days'|trans }}
+
+
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.accounts'|trans }}
+ {{ 'setono_sylius_loyalty.ui.accounts_card_description'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.earning_rules'|trans }}
+ {{ 'setono_sylius_loyalty.ui.earning_rules_card_description'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.tiers'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tiers_card_description'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.referrals'|trans }}
+ {{ 'setono_sylius_loyalty.ui.referrals_card_description'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.program'|trans }}
+ {{ 'setono_sylius_loyalty.ui.program_card_description'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.rule_tester'|trans }}
+ {{ 'setono_sylius_loyalty.ui.rule_tester_card_description'|trans }}
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.dry_run_results'|trans }}
+ {{ 'setono_sylius_loyalty.ui.dry_run_results_card_description'|trans }}
+
+
+
+{% endblock %}
diff --git a/src/Resources/views/admin/grid/field/tier.html.twig b/src/Resources/views/admin/grid/field/tier.html.twig
new file mode 100644
index 0000000..b489e63
--- /dev/null
+++ b/src/Resources/views/admin/grid/field/tier.html.twig
@@ -0,0 +1,3 @@
+{% if data %}
+ {{ data.name }}
+{% endif %}
diff --git a/src/Resources/views/admin/program/index.html.twig b/src/Resources/views/admin/program/index.html.twig
new file mode 100644
index 0000000..d8ac1ad
--- /dev/null
+++ b/src/Resources/views/admin/program/index.html.twig
@@ -0,0 +1,46 @@
+{% extends '@SyliusAdmin/layout.html.twig' %}
+
+{% block title %}{{ 'setono_sylius_loyalty.ui.program'|trans }} {{ parent() }}{% endblock %}
+
+{% block content %}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.program'|trans }}
+ {{ 'setono_sylius_loyalty.ui.program_card_description'|trans }}
+
+
+
+
+
+
+ {{ 'sylius.ui.channel'|trans }}
+ {{ 'setono_sylius_loyalty.form.program.award_order_points_at'|trans }}
+ {{ 'setono_sylius_loyalty.form.program.redemption_conversion_points'|trans }}
+ {{ 'setono_sylius_loyalty.form.program.points_expiry_days'|trans }}
+ {{ 'sylius.ui.actions'|trans }}
+
+
+
+ {% for row in programs %}
+
+ {{ row.channel.name }} ({{ row.channel.code }})
+ {{ row.program.awardOrderPointsAt }}
+ {{ row.program.redemptionConversionPoints }} = {{ row.program.redemptionConversionAmount }}
+ {{ row.program.pointsExpiryDays ?? 'setono_sylius_loyalty.ui.never'|trans }}
+
+
+
+ {{ 'sylius.ui.edit'|trans }}
+
+
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/src/Resources/views/admin/referral/_override.html.twig b/src/Resources/views/admin/referral/_override.html.twig
new file mode 100644
index 0000000..9ed11bd
--- /dev/null
+++ b/src/Resources/views/admin/referral/_override.html.twig
@@ -0,0 +1,10 @@
+{% if data.status == 'rejected' %}
+
+{% elseif data.status == 'rejected' or data.fraudFlags is not empty %}
+ {{ data.fraudFlags|map(f => f.check)|join(', ') }}
+{% endif %}
diff --git a/src/Resources/views/admin/rule_tester/index.html.twig b/src/Resources/views/admin/rule_tester/index.html.twig
new file mode 100644
index 0000000..8b80037
--- /dev/null
+++ b/src/Resources/views/admin/rule_tester/index.html.twig
@@ -0,0 +1,117 @@
+{% extends '@SyliusAdmin/layout.html.twig' %}
+
+{% block title %}{{ 'setono_sylius_loyalty.ui.rule_tester'|trans }} {{ parent() }}{% endblock %}
+
+{% block content %}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.rule_tester'|trans }}
+ {{ 'setono_sylius_loyalty.ui.rule_tester_card_description'|trans }}
+
+
+
+
+
+ {% if error is not null %}
+
+ {% endif %}
+
+ {% if result is not null %}
+
+
+ {{ 'setono_sylius_loyalty.ui.tester_rules'|trans }}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.rule'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tester_matched'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tester_failed_conditions'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tester_claimed_basis'|trans }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tester_applied'|trans }}
+
+
+
+ {% for evaluation in result.ruleEvaluations %}
+
+ {{ evaluation.rule.name }}{% if evaluation.factor is not null %} ×{{ evaluation.factor }}{% endif %}
+ {% if evaluation.matched %}{% else %}{% endif %}
+ {{ evaluation.failedConditions|join(', ') }}
+ {{ evaluation.claimedBasis }}
+ {{ evaluation.points }}
+ {% if evaluation.applied %}{% else %}{% endif %}
+
+ {% endfor %}
+
+
+
+ {% if result.dryRunEvaluations is not empty %}
+ {{ 'setono_sylius_loyalty.ui.dry_run_results'|trans }}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.rule'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tester_matched'|trans }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+
+
+
+ {% for evaluation in result.dryRunEvaluations %}
+
+ {{ evaluation.rule.name }}
+ {% if evaluation.matched %}{% else %}{% endif %}
+ {{ evaluation.points }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if order is not null %}
+ {{ 'setono_sylius_loyalty.ui.tester_item_claims'|trans }}
+
+
+
+ {{ 'sylius.ui.item'|trans }}
+ {{ 'setono_sylius_loyalty.ui.tester_claimed_by'|trans }}
+
+
+
+ {% for item in order.items %}
+
+ {{ item.productName }} (#{{ item.id }})
+
+ {% for evaluation in result.ruleEvaluations %}
+ {% if evaluation.applied and evaluation.claimedItems[item.id] is defined %}
+ {{ evaluation.rule.name }}: {{ evaluation.claimedItems[item.id] }}
+ {% endif %}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+ {% endif %}
+{% endblock %}
diff --git a/src/Resources/views/form/theme.html.twig b/src/Resources/views/form/theme.html.twig
new file mode 100644
index 0000000..c9c7469
--- /dev/null
+++ b/src/Resources/views/form/theme.html.twig
@@ -0,0 +1,4 @@
+{% block setono_sylius_loyalty_expression_widget %}
+ {{ block('textarea_widget') }}
+
+{% endblock %}
diff --git a/src/Resources/views/shop/account/loyalty/_referral.html.twig b/src/Resources/views/shop/account/loyalty/_referral.html.twig
new file mode 100644
index 0000000..22ec1ac
--- /dev/null
+++ b/src/Resources/views/shop/account/loyalty/_referral.html.twig
@@ -0,0 +1,38 @@
+{% if referral is not null %}
+ {{ 'setono_sylius_loyalty.ui.refer_a_friend'|trans }}
+
+ {{ 'setono_sylius_loyalty.ui.referral_explainer'|trans }}
+
+
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.your_code'|trans }}: {{ referral.code }}
+
+
+ {% if referral.stats.rewarded > 0 %}
+
+ {{ 'setono_sylius_loyalty.ui.referral_stats'|trans({'%count%': referral.stats.rewarded, '%points%': referral.stats.pointsEarned}) }}
+
+ {% endif %}
+
+
+
+{% endif %}
diff --git a/src/Resources/views/shop/account/loyalty/index.html.twig b/src/Resources/views/shop/account/loyalty/index.html.twig
new file mode 100644
index 0000000..4456294
--- /dev/null
+++ b/src/Resources/views/shop/account/loyalty/index.html.twig
@@ -0,0 +1,115 @@
+{% extends '@SyliusShop/Account/layout.html.twig' %}
+
+{% block title %}{{ 'setono_sylius_loyalty.ui.my_loyalty'|trans }} | {{ parent() }}{% endblock %}
+
+{% block subcontent %}
+
+
+
+ {{ 'setono_sylius_loyalty.ui.my_loyalty'|trans }}
+
+
+
+ {# Balance hero #}
+
+
+ {{ account ? account.balance : 0 }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+
+ {% if tierProgress is not null %}
+
+ {% if tierProgress.current %}
+
+ {{ tierProgress.current.name }}
+
+ {% if tierProgress.current.benefitsDescription %}
+ {{ tierProgress.current.benefitsDescription }}
+ {% endif %}
+ {% endif %}
+
+ {% if tierProgress.topTier %}
+
+ {% elseif tierProgress.next %}
+
+
+
+
+
+ {{ 'setono_sylius_loyalty.ui.tier_progress'|trans({'%metric%': tierProgress.metric, '%threshold%': tierProgress.threshold, '%tier%': tierProgress.next.name}) }}
+
+
+ {% endif %}
+
+ {% endif %}
+ {% if account is not null and not account.enabled %}
+
+ {% endif %}
+
+
+ {# Expiring-soon callout #}
+ {% if expiringSoon is not null %}
+
+ {% endif %}
+
+ {% include '@SetonoSyliusLoyaltyPlugin/shop/account/loyalty/_referral.html.twig' %}
+
+ {# Transaction history #}
+ {{ 'setono_sylius_loyalty.ui.history'|trans }}
+ {% if rows is empty %}
+ {{ 'setono_sylius_loyalty.ui.history_empty'|trans }}
+ {% else %}
+
+
+
+ {{ 'sylius.ui.date'|trans }}
+ {{ 'setono_sylius_loyalty.ui.description'|trans }}
+ {{ 'setono_sylius_loyalty.ui.points'|trans }}
+ {{ 'setono_sylius_loyalty.ui.running_balance'|trans }}
+
+
+
+ {% for row in rows %}
+ {% set transaction = row.transaction %}
+ {% set type = setono_sylius_loyalty_transaction_type(transaction) %}
+
+ {{ transaction.occurredAt|date('Y-m-d') }}
+
+ {% if type in ['earn_order', 'redeem'] and transaction.order is not null %}
+ {{ ('setono_sylius_loyalty.ui.history_description.' ~ type)|trans({'%order%': transaction.order.number}) }}
+ {% elseif type in ['manual_credit', 'manual_debit'] %}
+ {{ ('setono_sylius_loyalty.ui.history_description.' ~ type)|trans({'%reason%': ('setono_sylius_loyalty.ui.manual_reason.' ~ transaction.reason)|trans}) }}
+ {% else %}
+ {{ ('setono_sylius_loyalty.ui.history_description.' ~ type)|trans }}
+ {% endif %}
+ {% if transaction.points > 0 and transaction.expiresAt is defined and transaction.expiresAt is not null %}
+
+ · {{ 'setono_sylius_loyalty.ui.expires'|trans({'%date%': transaction.expiresAt|date('Y-m-d')}) }}
+
+ {% endif %}
+
+
+ {{ transaction.points > 0 ? '+' : '' }}{{ transaction.points }}
+
+ {{ row.runningBalance }}
+
+ {% endfor %}
+
+
+
+ {% if pages > 1 %}
+
+ {% endif %}
+ {% endif %}
+{% endblock %}
diff --git a/src/Resources/views/shop/cart/_earn_hint.html.twig b/src/Resources/views/shop/cart/_earn_hint.html.twig
new file mode 100644
index 0000000..4af8b70
--- /dev/null
+++ b/src/Resources/views/shop/cart/_earn_hint.html.twig
@@ -0,0 +1,8 @@
+{% set points = setono_sylius_loyalty_cart_earn_hint(cart) %}
+
+{% if points is not null %}
+
+
+ {{ 'setono_sylius_loyalty.ui.earn_hint_cart'|trans({'%points%': points}) }}
+
+{% endif %}
diff --git a/src/Resources/views/shop/cart/_redemption.html.twig b/src/Resources/views/shop/cart/_redemption.html.twig
new file mode 100644
index 0000000..6e247d3
--- /dev/null
+++ b/src/Resources/views/shop/cart/_redemption.html.twig
@@ -0,0 +1,55 @@
+{% import '@SyliusShop/Common/Macro/money.html.twig' as money %}
+
+{% set redemption = cart is defined ? setono_sylius_loyalty_cart_redemption(cart) : null %}
+
+{% if redemption is not null %}
+
+ {{ 'setono_sylius_loyalty.ui.use_your_points'|trans }}
+
+
+ {{ 'setono_sylius_loyalty.ui.balance'|trans }}:
+ {{ redemption.balance }}
+
+
+ {% if redemption.appliedPoints > 0 %}
+
+
+ {% if redemption.clamped %}
+
+ {% endif %}
+
+
+ {% endif %}
+
+ {% if redemption.canRedeem %}
+
+ {% endif %}
+
+{% endif %}
diff --git a/src/Resources/views/shop/checkout/_redemption_summary.html.twig b/src/Resources/views/shop/checkout/_redemption_summary.html.twig
new file mode 100644
index 0000000..482d2ae
--- /dev/null
+++ b/src/Resources/views/shop/checkout/_redemption_summary.html.twig
@@ -0,0 +1,12 @@
+{% import '@SyliusShop/Common/Macro/money.html.twig' as money %}
+
+{% set redemption = order is defined ? setono_sylius_loyalty_cart_redemption(order) : null %}
+
+{% if redemption is not null and redemption.appliedPoints > 0 %}
+
+ {{ 'setono_sylius_loyalty.ui.points_applied'|trans({'%points%': redemption.appliedPoints}) }}
+ ({{ '−' }}{{ money.convertAndFormat(redemption.appliedAmount) }})
+ —
+ {{ 'setono_sylius_loyalty.ui.change_on_cart'|trans }}
+
+{% endif %}
diff --git a/src/Resources/views/shop/product/_earn_hint.html.twig b/src/Resources/views/shop/product/_earn_hint.html.twig
new file mode 100644
index 0000000..48dddda
--- /dev/null
+++ b/src/Resources/views/shop/product/_earn_hint.html.twig
@@ -0,0 +1,24 @@
+{% set hint = setono_sylius_loyalty_product_earn_hint(product) %}
+
+{% if hint is not null %}
+
+
+ {{ 'setono_sylius_loyalty.ui.earn_hint_product'|trans({'%points%': hint.default}) }}
+
+ {# One entry per variant, keyed both by variant code and by option-value data
+ attributes so the map works for choice- and option-based selection alike #}
+
+
+
+{% endif %}
diff --git a/src/SetonoSyliusLoyaltyPlugin.php b/src/SetonoSyliusLoyaltyPlugin.php
index 45b9c9b..a305b05 100644
--- a/src/SetonoSyliusLoyaltyPlugin.php
+++ b/src/SetonoSyliusLoyaltyPlugin.php
@@ -4,10 +4,58 @@
namespace Setono\SyliusLoyaltyPlugin;
+use Setono\CompositeCompilerPass\CompositeCompilerPass;
+use Setono\SyliusLoyaltyPlugin\DependencyInjection\Compiler\RegisterEarningTriggersPass;
+use Setono\SyliusLoyaltyPlugin\EarningRule\Amount\AmountCalculatorRegistry;
+use Setono\SyliusLoyaltyPlugin\EarningRule\Checker\ConditionCheckerRegistry;
+use Setono\SyliusLoyaltyPlugin\Expression\Function\ExpressionFunctionRegistry;
+use Setono\SyliusLoyaltyPlugin\Referral\FraudCheck\CompositeReferralFraudCheck;
+use Setono\SyliusLoyaltyPlugin\Tier\QualificationBasis\TierQualificationBasisRegistry;
use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait;
-use Symfony\Component\HttpKernel\Bundle\Bundle;
+use Sylius\Bundle\ResourceBundle\AbstractResourceBundle;
+use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
+use Symfony\Component\DependencyInjection\Compiler\PassConfig;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
-final class SetonoSyliusLoyaltyPlugin extends Bundle
+final class SetonoSyliusLoyaltyPlugin extends AbstractResourceBundle
{
use SyliusPluginTrait;
+
+ public function build(ContainerBuilder $container): void
+ {
+ parent::build($container);
+
+ $container->addCompilerPass(new CompositeCompilerPass(
+ ConditionCheckerRegistry::class,
+ 'setono_sylius_loyalty.earning_condition',
+ ));
+ $container->addCompilerPass(new CompositeCompilerPass(
+ AmountCalculatorRegistry::class,
+ 'setono_sylius_loyalty.earning_amount',
+ ));
+ $container->addCompilerPass(new CompositeCompilerPass(
+ ExpressionFunctionRegistry::class,
+ 'setono_sylius_loyalty.expression_function',
+ ));
+ $container->addCompilerPass(new CompositeCompilerPass(
+ TierQualificationBasisRegistry::class,
+ 'setono_sylius_loyalty.tier_qualification_basis',
+ ));
+ $container->addCompilerPass(new CompositeCompilerPass(
+ CompositeReferralFraudCheck::class,
+ 'setono_sylius_loyalty.referral_fraud_check',
+ ));
+
+ // Must run before Symfony's RegisterListenersPass (same phase, priority 0) so the
+ // trigger listener tags it adds are picked up
+ $container->addCompilerPass(new RegisterEarningTriggersPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10);
+ }
+
+ /**
+ * @return list
+ */
+ public function getSupportedDrivers(): array
+ {
+ return [SyliusResourceBundle::DRIVER_DOCTRINE_ORM];
+ }
}
diff --git a/src/Tier/EvaluationWindowResolver.php b/src/Tier/EvaluationWindowResolver.php
new file mode 100644
index 0000000..dda0201
--- /dev/null
+++ b/src/Tier/EvaluationWindowResolver.php
@@ -0,0 +1,25 @@
+getTierEvaluationWindow()) {
+ LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_LIFETIME => null,
+ LoyaltyProgramInterface::TIER_EVALUATION_WINDOW_CALENDAR_YEAR => new DateRange(
+ $now->modify('first day of january this year')->setTime(0, 0),
+ $now->modify('first day of january next year')->setTime(0, 0),
+ ),
+ default => new DateRange($now->modify('-12 months'), $now),
+ };
+ }
+}
diff --git a/src/Tier/EvaluationWindowResolverInterface.php b/src/Tier/EvaluationWindowResolverInterface.php
new file mode 100644
index 0000000..ebc9b7c
--- /dev/null
+++ b/src/Tier/EvaluationWindowResolverInterface.php
@@ -0,0 +1,16 @@
+entityManager->createQueryBuilder()
+ ->select('COALESCE(SUM(o.total), 0)')
+ ->from($this->orderClass, 'o')
+ ->andWhere('o.customer = :customer')
+ ->andWhere('o.channel = :channel')
+ ->andWhere('o.paymentState = :paymentState')
+ ->setParameter('customer', $account->getCustomer())
+ ->setParameter('channel', $account->getChannel())
+ ->setParameter('paymentState', OrderPaymentStates::STATE_PAID)
+ ;
+
+ if (null !== $window) {
+ $qb->andWhere('o.checkoutCompletedAt >= :start')->setParameter('start', $window->start);
+ $qb->andWhere('o.checkoutCompletedAt <= :end')->setParameter('end', $window->end);
+ }
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+}
diff --git a/src/Tier/QualificationBasis/DateRange.php b/src/Tier/QualificationBasis/DateRange.php
new file mode 100644
index 0000000..43216aa
--- /dev/null
+++ b/src/Tier/QualificationBasis/DateRange.php
@@ -0,0 +1,14 @@
+entityManager->createQueryBuilder()
+ ->select('COUNT(o.id)')
+ ->from($this->orderClass, 'o')
+ ->andWhere('o.customer = :customer')
+ ->andWhere('o.channel = :channel')
+ ->andWhere('o.paymentState = :paymentState')
+ ->setParameter('customer', $account->getCustomer())
+ ->setParameter('channel', $account->getChannel())
+ ->setParameter('paymentState', OrderPaymentStates::STATE_PAID)
+ ;
+
+ if (null !== $window) {
+ $qb->andWhere('o.checkoutCompletedAt >= :start')->setParameter('start', $window->start);
+ $qb->andWhere('o.checkoutCompletedAt <= :end')->setParameter('end', $window->end);
+ }
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+}
diff --git a/src/Tier/QualificationBasis/PointsEarnedBasis.php b/src/Tier/QualificationBasis/PointsEarnedBasis.php
new file mode 100644
index 0000000..e0384f3
--- /dev/null
+++ b/src/Tier/QualificationBasis/PointsEarnedBasis.php
@@ -0,0 +1,62 @@
+entityManager->createQueryBuilder()
+ ->select('COALESCE(SUM(t.points), 0)')
+ ->from($this->transactionClass, 't')
+ ->andWhere('t.account = :account')
+ ->andWhere('t.points > 0')
+ ->andWhere('t NOT INSTANCE OF :rollback')
+ ->setParameter('account', $account)
+ ->setParameter('rollback', $this->entityManager->getClassMetadata(RedeemRollbackLoyaltyTransaction::class))
+ ;
+
+ if (null !== $window) {
+ $qb->andWhere('t.occurredAt >= :start')->setParameter('start', $window->start);
+ $qb->andWhere('t.occurredAt <= :end')->setParameter('end', $window->end);
+ }
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+}
diff --git a/src/Tier/QualificationBasis/TierQualificationBasisInterface.php b/src/Tier/QualificationBasis/TierQualificationBasisInterface.php
new file mode 100644
index 0000000..a5e8c41
--- /dev/null
+++ b/src/Tier/QualificationBasis/TierQualificationBasisInterface.php
@@ -0,0 +1,35 @@
+
+ */
+final class TierQualificationBasisRegistry extends CompositeService implements TierQualificationBasisRegistryInterface
+{
+ public function get(string $code): TierQualificationBasisInterface
+ {
+ $available = [];
+ foreach ($this->services as $basis) {
+ if ($basis->getCode() === $code) {
+ return $basis;
+ }
+ $available[] = $basis->getCode();
+ }
+
+ throw InvalidTierQualificationBasisException::unknown($code, $available);
+ }
+
+ public function all(): array
+ {
+ return $this->services;
+ }
+}
diff --git a/src/Tier/QualificationBasis/TierQualificationBasisRegistryInterface.php b/src/Tier/QualificationBasis/TierQualificationBasisRegistryInterface.php
new file mode 100644
index 0000000..f1266bc
--- /dev/null
+++ b/src/Tier/QualificationBasis/TierQualificationBasisRegistryInterface.php
@@ -0,0 +1,18 @@
+
+ */
+ public function all(): array;
+}
diff --git a/src/Tier/TierEvaluator.php b/src/Tier/TierEvaluator.php
new file mode 100644
index 0000000..93a71c5
--- /dev/null
+++ b/src/Tier/TierEvaluator.php
@@ -0,0 +1,135 @@
+qualifiedTier($account);
+ if (null === $qualified) {
+ return;
+ }
+
+ $current = $account->getTier();
+ if (null !== $current && $qualified->getPosition() <= $current->getPosition()) {
+ return;
+ }
+
+ $this->change($account, $qualified);
+ $account->setTierBelowThresholdSince(null);
+ }
+
+ /**
+ * Nightly reconciliation: upgrades apply, downgrades apply immediately or after the
+ * program's grace period, counted from when the account first evaluated below its tier.
+ */
+ public function reconcile(LoyaltyAccountInterface $account, \DateTimeImmutable $now): void
+ {
+ $qualified = $this->qualifiedTier($account);
+ $current = $account->getTier();
+
+ $qualifiedPosition = $qualified?->getPosition() ?? \PHP_INT_MIN;
+ $currentPosition = null === $current ? \PHP_INT_MIN : $current->getPosition();
+
+ if ($qualifiedPosition === $currentPosition) {
+ $account->setTierBelowThresholdSince(null);
+
+ return;
+ }
+
+ if ($qualifiedPosition > $currentPosition) {
+ $this->change($account, $qualified);
+ $account->setTierBelowThresholdSince(null);
+
+ return;
+ }
+
+ // Below the current tier: start or continue the grace clock
+ $channel = $account->getChannel();
+ \assert($channel instanceof ChannelInterface);
+ $graceDays = $this->programProvider->getByChannel($channel)->getTierDowngradeGraceDays();
+
+ $belowSince = $account->getTierBelowThresholdSince();
+ if (null === $belowSince) {
+ $account->setTierBelowThresholdSince($now);
+ $belowSince = $now;
+ }
+
+ if ($belowSince->modify(sprintf('+%d days', $graceDays)) <= $now) {
+ $this->change($account, $qualified);
+ $account->setTierBelowThresholdSince(null);
+ }
+ }
+
+ /**
+ * The highest enabled tier whose threshold the account meets on that tier's own basis.
+ */
+ private function qualifiedTier(LoyaltyAccountInterface $account): ?TierInterface
+ {
+ $channel = $account->getChannel();
+ if (!$channel instanceof ChannelInterface) {
+ return null;
+ }
+
+ $tiers = $this->tierRepository->findQualifiable($channel);
+ if ([] === $tiers) {
+ return null;
+ }
+
+ $window = $this->windowResolver->resolve($this->programProvider->getByChannel($channel));
+
+ /** @var array $metrics */
+ $metrics = [];
+ foreach ($tiers as $tier) {
+ $basisCode = $tier->getQualificationBasis();
+ $metrics[$basisCode] ??= $this->basisRegistry->get($basisCode)->calculate($account, $window);
+
+ if ($metrics[$basisCode] >= $tier->getThreshold()) {
+ return $tier;
+ }
+ }
+
+ return null;
+ }
+
+ private function change(LoyaltyAccountInterface $account, ?TierInterface $to): void
+ {
+ $from = $account->getTier();
+
+ $changing = new TierChanging($account, $from, $to);
+ $this->eventDispatcher->dispatch($changing);
+ if ($changing->isCancelled()) {
+ return;
+ }
+
+ $account->setTier($to);
+
+ $this->eventDispatcher->dispatch(new TierChanged($account, $from, $to));
+ }
+}
diff --git a/src/Tier/TierEvaluatorInterface.php b/src/Tier/TierEvaluatorInterface.php
new file mode 100644
index 0000000..7edf4ed
--- /dev/null
+++ b/src/Tier/TierEvaluatorInterface.php
@@ -0,0 +1,25 @@
+ $accountClass
+ */
+ public function __construct(
+ private readonly CartRedemptionViewProviderInterface $cartRedemptionViewProvider,
+ private readonly LoyaltyTransactionRepositoryInterface $transactionRepository,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly EarnHintCalculatorInterface $earnHintCalculator,
+ private readonly LoyaltyProgramProviderInterface $programProvider,
+ private readonly ChannelContextInterface $channelContext,
+ private readonly CustomerContextInterface $customerContext,
+ private readonly string $accountClass,
+ ) {
+ }
+
+ public function cartRedemption(OrderInterface $cart): ?CartRedemptionView
+ {
+ return $this->cartRedemptionViewProvider->getView($cart);
+ }
+
+ /**
+ * The transaction's discriminator value (e.g. "earn_order", "redeem").
+ */
+ public function transactionType(LoyaltyTransactionInterface $transaction): string
+ {
+ $discriminator = $this->entityManager->getClassMetadata($transaction::class)->discriminatorValue;
+
+ return is_string($discriminator) ? $discriminator : $transaction::class;
+ }
+
+ /**
+ * @return list
+ */
+ public function accountsOf(CustomerInterface $customer): array
+ {
+ /** @var list $accounts */
+ $accounts = $this->entityManager->getRepository($this->accountClass)->findBy(['customer' => $customer]);
+
+ return $accounts;
+ }
+
+ /**
+ * The account's latest transactions, newest first.
+ *
+ * @return list
+ */
+ public function latestTransactions(LoyaltyAccountInterface $account, int $limit = 25): array
+ {
+ return array_slice(array_reverse($this->transactionRepository->findForReplay($account)), 0, $limit);
+ }
+
+ /**
+ * Per-variant earn hint for the product page, or null when hidden (toggle off, no
+ * applicable rules, disabled account).
+ *
+ * @return array{default: int, variants: array}|null
+ */
+ public function productEarnHint(ProductInterface $product): ?array
+ {
+ $channel = $this->channelContext->getChannel();
+ if (!$channel instanceof ChannelInterface || !$this->programProvider->getByChannel($channel)->isShowEarnableOnProduct()) {
+ return null;
+ }
+
+ $customer = $this->customerContext->getCustomer();
+ $customer = $customer instanceof CustomerInterface ? $customer : null;
+
+ $variants = [];
+ $default = null;
+ foreach ($product->getEnabledVariants() as $variant) {
+ \assert($variant instanceof ProductVariantInterface);
+ $points = $this->earnHintCalculator->forVariant($variant, $channel, $customer);
+ $variants[(string) $variant->getCode()] = $points;
+ $default ??= $points;
+ }
+
+ return null === $default ? null : ['default' => $default, 'variants' => $variants];
+ }
+
+ /**
+ * "This order earns ~N points", or null when hidden.
+ */
+ public function cartEarnHint(OrderInterface $cart): ?int
+ {
+ $channel = $cart->getChannel();
+ if (!$channel instanceof ChannelInterface || !$this->programProvider->getByChannel($channel)->isShowEarnableInCart()) {
+ return null;
+ }
+
+ $customer = $cart->getCustomer();
+
+ return $this->earnHintCalculator->forCart($cart, $customer instanceof CustomerInterface ? $customer : null);
+ }
+}
diff --git a/src/Validator/Constraints/LoyaltyRedemptionValid.php b/src/Validator/Constraints/LoyaltyRedemptionValid.php
new file mode 100644
index 0000000..c0685fa
--- /dev/null
+++ b/src/Validator/Constraints/LoyaltyRedemptionValid.php
@@ -0,0 +1,30 @@
+appliedPointsProvider->getAppliedPoints($value);
+ if ($appliedPoints <= 0) {
+ return;
+ }
+
+ $customer = $value->getCustomer();
+ $channel = $value->getChannel();
+ if (!$customer instanceof CustomerInterface || null === $channel) {
+ return;
+ }
+
+ $account = $this->accountRepository->findOneByCustomerAndChannel($customer, $channel);
+
+ if (null === $account || !$account->isEnabled()) {
+ $this->context->buildViolation($constraint->accountDisabledMessage)->addViolation();
+
+ return;
+ }
+
+ if ($account->getBalance() < $appliedPoints) {
+ $this->context->buildViolation($constraint->insufficientBalanceMessage)->addViolation();
+ }
+ }
+}
diff --git a/tests/Application/.env b/tests/Application/.env
index f4370e8..5ee199a 100644
--- a/tests/Application/.env
+++ b/tests/Application/.env
@@ -23,13 +23,13 @@ APP_SECRET=EDITME
# Format described at https://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# For a sqlite database, use: "sqlite:///%kernel.project_dir%/var/data.db"
# Set "serverVersion" to your server version to avoid edge-case exceptions and extra database calls
-DATABASE_URL=mysql://root@127.0.0.1/acme_sylius_skeleton_%kernel.environment%?serverVersion=11.6.2-MariaDB
+DATABASE_URL=mysql://root@127.0.0.1/setono_sylius_loyalty_%kernel.environment%?serverVersion=11.6.2-MariaDB
###< doctrine/doctrine-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
-JWT_PASSPHRASE=acme_plugin_development
+JWT_PASSPHRASE=setono_sylius_loyalty_development
###< lexik/jwt-authentication-bundle ###
###> symfony/messenger ###
diff --git a/tests/Application/Entity/Order.php b/tests/Application/Entity/Order.php
new file mode 100644
index 0000000..ebec709
--- /dev/null
+++ b/tests/Application/Entity/Order.php
@@ -0,0 +1,17 @@
+ ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
+ DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
];
diff --git a/tests/Application/config/packages/doctrine.yaml b/tests/Application/config/packages/doctrine.yaml
index f51ba5a..1b33c51 100644
--- a/tests/Application/config/packages/doctrine.yaml
+++ b/tests/Application/config/packages/doctrine.yaml
@@ -12,3 +12,11 @@ doctrine:
charset: UTF8
url: '%env(resolve:DATABASE_URL)%'
+
+ orm:
+ mappings:
+ TestApplication:
+ is_bundle: false
+ type: attribute
+ dir: '%kernel.project_dir%/Entity'
+ prefix: 'Setono\SyliusLoyaltyPlugin\Tests\Application\Entity'
diff --git a/tests/Application/config/packages/messenger.yaml b/tests/Application/config/packages/messenger.yaml
new file mode 100644
index 0000000..524ea05
--- /dev/null
+++ b/tests/Application/config/packages/messenger.yaml
@@ -0,0 +1,6 @@
+# The loyalty_async transport exists for the async awarding functional test: nothing routes
+# to it by default (awarding stays sync), the test targets it with a TransportNamesStamp.
+framework:
+ messenger:
+ transports:
+ loyalty_async: 'doctrine://default?queue_name=loyalty_test'
diff --git a/tests/Application/config/packages/setono_sylius_loyalty.yaml b/tests/Application/config/packages/setono_sylius_loyalty.yaml
index 78e8885..5bea7d3 100644
--- a/tests/Application/config/packages/setono_sylius_loyalty.yaml
+++ b/tests/Application/config/packages/setono_sylius_loyalty.yaml
@@ -1,2 +1,4 @@
setono_sylius_loyalty:
- option: Option value
+ expression_editor:
+ # Vendored snapshot so CI never depends on CDN reachability
+ cdn_base_url: '/vendor/codemirror'
diff --git a/tests/Application/config/packages/sylius_fixtures_loyalty.yaml b/tests/Application/config/packages/sylius_fixtures_loyalty.yaml
new file mode 100644
index 0000000..bf413b1
--- /dev/null
+++ b/tests/Application/config/packages/sylius_fixtures_loyalty.yaml
@@ -0,0 +1,114 @@
+# The deterministic loyalty seed for functional and e2e tests. Loaded on top of the default
+# suite (no purge listeners): sylius:fixtures:load default && sylius:fixtures:load loyalty
+sylius_fixtures:
+ suites:
+ loyalty:
+ fixtures:
+ setono_sylius_loyalty_program:
+ options:
+ custom:
+ - channel: FASHION_WEB
+ min_redeem_points: 500
+ max_redeem_percent_of_order: 50
+ points_expiry_days: 365
+
+ setono_sylius_loyalty_tier:
+ options:
+ custom:
+ - code: silver
+ name: Silver
+ channel: FASHION_WEB
+ position: 1
+ threshold: 1000
+ earning_multiplier: 1.25
+ color: '#c0c0c0'
+ benefits_description: 'A 25% points bonus on everything you buy.'
+
+ - code: gold
+ name: Gold
+ channel: FASHION_WEB
+ position: 2
+ threshold: 5000
+ earning_multiplier: 1.5
+ color: '#d4af37'
+ benefits_description: 'A 50% points bonus and access to member events.'
+
+ setono_sylius_loyalty_earning_rule:
+ options:
+ custom:
+ - name: 'Base rate: 1 point per 1.00'
+ channel: FASHION_WEB
+ amount_type: per_amount
+ amount_configuration:
+ points: 1
+ per_amount: 100
+
+ - name: 'Weekend double points (dry run)'
+ channel: FASHION_WEB
+ amount_type: multiplier
+ amount_configuration:
+ factor: 2
+ conditions:
+ - type: day_of_week
+ configuration:
+ days: [6, 7]
+ dry_run: true
+
+ - name: 'Registration bonus'
+ channel: FASHION_WEB
+ trigger: customer_registered
+ amount_type: fixed
+ amount_configuration:
+ points: 100
+
+ - name: 'Big-cart expression bonus (dry run)'
+ channel: FASHION_WEB
+ amount_type: expression
+ amount_configuration:
+ expression: 'basis > 50000 ? floor(basis / 50) : floor(basis / 100)'
+ dry_run: true
+
+ setono_sylius_loyalty_account:
+ options:
+ custom:
+ - email: loyalty@example.com
+ channel: FASHION_WEB
+ history:
+ - type: manual_credit
+ points: 2000
+ reason: goodwill
+ note: 'Fixture seed'
+ - type: earn_action
+ points: 300
+ source_identifier: 'fixture:expiring'
+ expires_at: '+10 days'
+ - type: manual_debit
+ points: 150
+ reason: correction
+ note: 'Fixture debit'
+
+ - email: fresh@example.com
+ channel: FASHION_WEB
+ history:
+ - type: manual_credit
+ points: 100
+ reason: goodwill
+ note: 'Below the redemption minimum'
+
+ # Dedicated to the shop dashboard e2e spec — no other spec may
+ # touch this account, so its history stays exact
+ - email: history@example.com
+ channel: FASHION_WEB
+ history:
+ - type: manual_credit
+ points: 2000
+ reason: goodwill
+ note: 'Fixture seed'
+ - type: earn_action
+ points: 300
+ source_identifier: 'fixture:history-expiring'
+ expires_at: '+10 days'
+ - type: manual_debit
+ points: 150
+ reason: correction
+ note: 'Fixture debit'
diff --git a/tests/Application/config/packages/sylius_order_override.yaml b/tests/Application/config/packages/sylius_order_override.yaml
new file mode 100644
index 0000000..9f43f61
--- /dev/null
+++ b/tests/Application/config/packages/sylius_order_override.yaml
@@ -0,0 +1,5 @@
+sylius_order:
+ resources:
+ order:
+ classes:
+ model: Setono\SyliusLoyaltyPlugin\Tests\Application\Entity\Order
diff --git a/tests/Application/public/vendor/codemirror/bundle.js b/tests/Application/public/vendor/codemirror/bundle.js
new file mode 100644
index 0000000..d4c6acb
--- /dev/null
+++ b/tests/Application/public/vendor/codemirror/bundle.js
@@ -0,0 +1,14 @@
+var Fd=Object.defineProperty;var Tn=(n,e)=>{for(var t in e)Fd(n,t,{get:e[t],enumerable:!0})};var Nd={};Tn(Nd,{EditorView:()=>A,basicSetup:()=>jb,minimalSetup:()=>Ub});var yr=[],Ia=[];(()=>{let n="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,3b,f,,w,1j,,,,4,8,4,,3,7,a,2,t,,1m,,,,2,4,8,,9,,a,2,q,,2,2,1l,,4,2,4,2,2,3,3,,u,2,3,,b,2,1l,,4,5,,2,4,,k,2,m,6,,,1m,,,2,,4,8,,7,3,a,2,u,,1n,,,,c,,9,,14,,3,,1l,3,5,3,,4,7,2,b,2,t,,1m,,2,,2,,3,,5,2,7,2,b,2,s,2,1l,2,,,2,4,8,,9,,a,2,t,,20,,4,,2,3,,,8,,29,,2,7,c,8,2q,,2,9,b,6,22,2,r,,,,,,1j,e,,5,,2,5,b,,10,9,,2u,4,,6,,2,2,2,p,2,4,3,g,4,d,,2,2,6,,f,,jj,3,qa,3,t,3,t,2,u,2,1s,2,,7,8,,2,b,9,,19,3,3b,2,y,,3a,3,4,2,9,,6,3,63,2,2,,1m,,,7,,,,,2,8,6,a,2,,1c,h,1r,4,1c,7,,,5,,14,9,c,2,w,4,2,2,,3,1k,,,2,3,,,3,1m,8,2,2,48,3,,d,,7,4,,6,,3,2,5i,1m,,5,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,p,2,5,,47,2,q,i,d,,12,8,p,b,1a,3,1c,,2,4,2,2,13,,1v,6,2,2,2,2,c,,8,,1b,,1f,,,3,2,2,5,2,,,16,2,8,,6m,,2,,4,,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,ar,2,49,b,4w,,1i,f,1k,3,1d,4,2,2,1x,3,10,5,,8,1q,,c,2,1g,9,a,4,2,,2n,3,2,,,2,6,,4g,,3,8,l,2,1l,2,,,,,m,,e,7,3,5,5f,8,2,3,,,n,,29,,2,6,,,2,,,2,,2,6j,,2,4,6,2,,2,r,2,2d,8,2,,,2,2y,,,,2,6,,,2t,3,2,4,,5,77,9,,2,6t,,a,2,,,4,,40,4,2,2,4,,w,a,14,6,2,4,8,,9,6,2,3,1a,d,,2,ba,7,,6,,,2a,m,2,7,,2,,2,3e,6,3,,,2,,7,,,20,2,3,,,,9n,2,f0b,5,1n,7,t4,,1r,4,29,,f5k,2,43q,,,3,4,5,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,79,7,c5,4,15s,7,31,7,240,5,gx7k,2o,3k,6o".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e>1;if(n=Ia[i])e=i+1;else return!0;if(e==t)return!1}}function Ea(n){return n>=127462&&n<=127487}var Ra=8205;function Na(n,e,t=!0,i=!0){return(t?Fa:Wd)(n,e,i)}function Fa(n,e,t){if(e==n.length)return e;e&&Ha(n.charCodeAt(e))&&Wa(n.charCodeAt(e-1))&&e--;let i=gr(n,e);for(e+=Pa(i);e=0&&Ea(gr(n,o));)r++,o-=2;if(r%2==0)break;e+=2}else break}return e}function Wd(n,e,t){for(;e>1;){let i=Fa(n,e-2,t);if(i=56320&&n<57344}function Wa(n){return n>=55296&&n<56320}function Pa(n){return n<65536?1:2}var F=class n{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){[e,t]=ri(this,e,t);let s=[];return this.decompose(0,e,s,2),i.length&&i.decompose(0,i.length,s,3),this.decompose(t,this.length,s,1),ii.from(s,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=ri(this,e,t);let i=[];return this.decompose(e,t,i,0),ii.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),s=new Pt(this),r=new Pt(e);for(let o=t,l=t;;){if(s.next(o),r.next(o),o=0,s.lineBreak!=r.lineBreak||s.done!=r.done||s.value!=r.value)return!1;if(l+=s.value.length,s.done||l>=i)return!0}}iter(e=1){return new Pt(this,e)}iterRange(e,t=this.length){return new En(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{t==null&&(t=this.lines+1);let s=this.line(e).from;i=this.iterRange(s,Math.max(s,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new Rn(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?n.empty:e.length<=32?new Te(e):ii.from(Te.split(e,[]))}},Te=class n extends F{constructor(e,t=Vd(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.text[r],l=s+o.length;if((t?i:l)>=e)return new xr(s,l,i,o);s=l+1,i++}}decompose(e,t,i,s){let r=e<=0&&t>=this.length?this:new n(Va(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(s&1){let o=i.pop(),l=Ln(r.text,o.text.slice(),0,r.length);if(l.length<=32)i.push(new n(l,o.length+r.length));else{let a=l.length>>1;i.push(new n(l.slice(0,a)),new n(l.slice(a)))}}else i.push(r)}replace(e,t,i){if(!(i instanceof n))return super.replace(e,t,i);[e,t]=ri(this,e,t);let s=Ln(this.text,Ln(i.text,Va(this.text,0,e)),t),r=this.length+i.length-(t-e);return s.length<=32?new n(s,r):ii.from(n.split(s,[]),r)}sliceString(e,t=this.length,i=`
+`){[e,t]=ri(this,e,t);let s="";for(let r=0,o=0;r<=t&&oe&&o&&(s+=i),er&&(s+=l.slice(Math.max(0,e-r),t-r)),r=a+1}return s}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],s=-1;for(let r of e)i.push(r),s+=r.length+1,i.length==32&&(t.push(new n(i,s)),i=[],s=-1);return s>-1&&t.push(new n(i,s)),t}},ii=class n extends F{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,s){for(let r=0;;r++){let o=this.children[r],l=s+o.length,a=i+o.lines-1;if((t?a:l)>=e)return o.lineInner(e,t,i,s);s=l+1,i=a+1}}decompose(e,t,i,s){for(let r=0,o=0;o<=t&&r=o){let h=s&((o<=e?1:0)|(a>=t?2:0));o>=e&&a<=t&&!h?i.push(l):l.decompose(e-o,t-o,i,h)}o=a+1}}replace(e,t,i){if([e,t]=ri(this,e,t),i.lines=r&&t<=l){let a=o.replace(e-r,t-r,i),h=this.lines-o.lines+a.lines;if(a.lines>4&&a.lines>h>>6){let c=this.children.slice();return c[s]=a,new n(c,this.length-(t-e)+i.length)}return super.replace(r,l,a)}r=l+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=`
+`){[e,t]=ri(this,e,t);let s="";for(let r=0,o=0;re&&r&&(s+=i),eo&&(s+=l.sliceString(e-o,t-o,i)),o=a+1}return s}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof n))return 0;let i=0,[s,r,o,l]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;s+=t,r+=t){if(s==o||r==l)return i;let a=this.children[s],h=e.children[r];if(a!=h)return i+a.scanIdentical(h,t);i+=a.length+1}}static from(e,t=e.reduce((i,s)=>i+s.length+1,-1)){let i=0;for(let d of e)i+=d.lines;if(i<32){let d=[];for(let p of e)p.flatten(d);return new Te(d,t)}let s=Math.max(32,i>>5),r=s<<1,o=s>>1,l=[],a=0,h=-1,c=[];function f(d){let p;if(d.lines>r&&d instanceof n)for(let m of d.children)f(m);else d.lines>o&&(a>o||!a)?(u(),l.push(d)):d instanceof Te&&a&&(p=c[c.length-1])instanceof Te&&d.lines+p.lines<=32?(a+=d.lines,h+=d.length+1,c[c.length-1]=new Te(p.text.concat(d.text),p.length+1+d.length)):(a+d.lines>s&&u(),a+=d.lines,h+=d.length+1,c.push(d))}function u(){a!=0&&(l.push(c.length==1?c[0]:n.from(c,h)),h=-1,a=c.length=0)}for(let d of e)f(d);return u(),l.length==1?l[0]:new n(l,t)}};F.empty=new Te([""],0);function Vd(n){let e=-1;for(let t of n)e+=t.length+1;return e}function Ln(n,e,t=0,i=1e9){for(let s=0,r=0,o=!0;r=t&&(a>i&&(l=l.slice(0,i-s)),s0?1:(e instanceof Te?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let i=this.nodes.length-1,s=this.nodes[i],r=this.offsets[i],o=r>>1,l=s instanceof Te?s.text.length:s.children.length;if(o==(t>0?l:0)){if(i==0)return this.done=!0,this.value="",this;t>0&&this.offsets[i-1]++,this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=`
+`,this;e--}else if(s instanceof Te){let a=s.text[o+(t<0?-1:0)];if(this.offsets[i]+=t,a.length>Math.max(0,e))return this.value=e==0?a:t>0?a.slice(e):a.slice(0,a.length-e),this;e-=a.length}else{let a=s.children[o+(t<0?-1:0)];e>a.length?(e-=a.length,this.offsets[i]+=t):(t<0&&this.offsets[i]--,this.nodes.push(a),this.offsets.push(t>0?1:(a instanceof Te?a.text.length:a.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}},En=class{constructor(e,t,i){this.value="",this.done=!1,this.cursor=new Pt(e,t>i?-1:1),this.pos=t>i?e.length:0,this.from=Math.min(t,i),this.to=Math.max(t,i)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let i=t<0?this.pos-this.from:this.to-this.pos;e>i&&(e=i),i-=e;let{value:s}=this.cursor.next(e);return this.pos+=(s.length+e)*t,this.value=s.length<=i?s:t<0?s.slice(s.length-i):s.slice(0,i),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}},Rn=class{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:i,value:s}=this.inner.next(e);return t&&this.afterBreak?(this.value="",this.afterBreak=!1):t?(this.done=!0,this.value=""):i?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=s,this.afterBreak=!1),this}get lineBreak(){return!1}};typeof Symbol<"u"&&(F.prototype[Symbol.iterator]=function(){return this.iter()},Pt.prototype[Symbol.iterator]=En.prototype[Symbol.iterator]=Rn.prototype[Symbol.iterator]=function(){return this});var xr=class{constructor(e,t,i,s){this.from=e,this.to=t,this.number=i,this.text=s}get length(){return this.to-this.from}};function ri(n,e,t){return e=Math.max(0,Math.min(n.length,e)),[e,Math.max(e,Math.min(n.length,t))]}function te(n,e,t=!0,i=!0){return Na(n,e,t,i)}function zd(n){return n>=56320&&n<57344}function qd(n){return n>=55296&&n<56320}function he(n,e){let t=n.charCodeAt(e);if(!qd(t)||e+1==n.length)return t;let i=n.charCodeAt(e+1);return zd(i)?(t-55296<<10)+(i-56320)+65536:t}function Ii(n){return n<=65535?String.fromCharCode(n):(n-=65536,String.fromCharCode((n>>10)+55296,(n&1023)+56320))}function De(n){return n<65536?1:2}var wr=/\r\n?|\n/,se=(function(n){return n[n.Simple=0]="Simple",n[n.TrackDel=1]="TrackDel",n[n.TrackBefore=2]="TrackBefore",n[n.TrackAfter=3]="TrackAfter",n})(se||(se={})),rt=class n{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;te)return r+(e-s);r+=l}else{if(i!=se.Simple&&h>=e&&(i==se.TrackDel&&se||i==se.TrackBefore&&se))return null;if(h>e||h==e&&t<0&&!l)return e==s||t<0?r:r+a;r+=a}s=h}if(e>s)throw new RangeError(`Position ${e} is out of range for changeset of length ${s}`);return r}touchesRange(e,t=e){for(let i=0,s=0;i=0&&s<=t&&l>=e)return st?"cover":!0;s=l}return!1}toString(){let e="";for(let t=0;t=0?":"+s:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new n(e)}static create(e){return new n(e)}},ye=class n extends rt{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return vr(this,(t,i,s,r,o)=>e=e.replace(s,s+(i-t),o),!1),e}mapDesc(e,t=!1){return kr(this,e,t,!0)}invert(e){let t=this.sections.slice(),i=[];for(let s=0,r=0;s=0){t[s]=l,t[s+1]=o;let a=s>>1;for(;i.length0&>(i,t,r.text),r.forward(c),l+=c}let h=e[o++];for(;l>1].toJSON()))}return e}static of(e,t,i){let s=[],r=[],o=0,l=null;function a(c=!1){if(!c&&!s.length)return;ou||f<0||u>t)throw new RangeError(`Invalid change range ${f} to ${u} (in doc of length ${t})`);let p=d?typeof d=="string"?F.of(d.split(i||wr)):d:F.empty,m=p.length;if(f==u&&m==0)return;fo&&de(s,f-o,-1),de(s,u-f,m),gt(r,s,p),o=u}}return h(e),a(!l),l}static empty(e){return new n(e?[e,-1]:[],[])}static fromJSON(e){if(!Array.isArray(e))throw new RangeError("Invalid JSON representation of ChangeSet");let t=[],i=[];for(let s=0;sl&&typeof o!="string"))throw new RangeError("Invalid JSON representation of ChangeSet");if(r.length==1)t.push(r[0],0);else{for(;i.length=0&&t<=0&&t==n[s+1]?n[s]+=e:s>=0&&e==0&&n[s]==0?n[s+1]+=t:i?(n[s]+=e,n[s+1]+=t):n.push(e,t)}function gt(n,e,t){if(t.length==0)return;let i=e.length-2>>1;if(i>1])),!(t||o==n.sections.length||n.sections[o+1]<0);)l=n.sections[o++],a=n.sections[o++];e(s,h,r,c,f),s=h,r=c}}}function kr(n,e,t,i=!1){let s=[],r=i?[]:null,o=new It(n),l=new It(e);for(let a=-1;;){if(o.done&&l.len||l.done&&o.len)throw new Error("Mismatched change set lengths");if(o.ins==-1&&l.ins==-1){let h=Math.min(o.len,l.len);de(s,h,-1),o.forward(h),l.forward(h)}else if(l.ins>=0&&(o.ins<0||a==o.i||o.off==0&&(l.len=0&&a=0){let h=0,c=o.len;for(;c;)if(l.ins==-1){let f=Math.min(c,l.len);h+=f,c-=f,l.forward(f)}else if(l.ins==0&&l.lena||o.ins>=0&&o.len>a)&&(l||i.length>h),r.forward2(a),o.forward(a)}}}}var It=class{constructor(e){this.set=e,this.i=0,this.next()}next(){let{sections:e}=this.set;this.i>1;return t>=e.length?F.empty:e[t]}textBit(e){let{inserted:t}=this.set,i=this.i-2>>1;return i>=t.length&&!e?F.empty:t[i].slice(this.off,e==null?void 0:this.off+e)}forward(e){e==this.len?this.next():(this.len-=e,this.off+=e)}forward2(e){this.ins==-1?this.forward(e):e==this.ins?this.next():(this.ins-=e,this.off+=e)}},Lt=class n{constructor(e,t,i,s){this.from=e,this.to=t,this.flags=i,this.goalColumn=s}get anchor(){return this.flags&32?this.to:this.from}get head(){return this.flags&32?this.from:this.to}get empty(){return this.from==this.to}get assoc(){return this.flags&8?-1:this.flags&16?1:0}get undirectional(){return(this.flags&64)>0}get bidiLevel(){let e=this.flags&7;return e==7?null:e}map(e,t=-1){let i,s;return this.empty?i=s=e.mapPos(this.from,t):(i=e.mapPos(this.from,1),s=e.mapPos(this.to,-1)),i==this.from&&s==this.to?this:new n(i,s,this.flags,this.goalColumn)}extend(e,t=e,i=0){if(e<=this.anchor&&t>=this.anchor)return b.range(e,t,void 0,void 0,i);let s=Math.abs(e-this.anchor)>Math.abs(t-this.anchor)?e:t;return b.range(this.anchor,s,void 0,void 0,i)}eq(e,t=!1){return this.anchor==e.anchor&&this.head==e.head&&this.goalColumn==e.goalColumn&&(!t||!this.empty||this.assoc==e.assoc)}toJSON(){return{anchor:this.anchor,head:this.head}}static fromJSON(e){if(!e||typeof e.anchor!="number"||typeof e.head!="number")throw new RangeError("Invalid JSON representation for SelectionRange");return b.range(e.anchor,e.head)}static create(e,t,i,s){return new n(e,t,i,s)}},b=class n{constructor(e,t){this.ranges=e,this.mainIndex=t}map(e,t=-1){return e.empty?this:n.create(this.ranges.map(i=>i.map(e,t)),this.mainIndex)}eq(e,t=!1){if(this.ranges.length!=e.ranges.length||this.mainIndex!=e.mainIndex)return!1;for(let i=0;ie.toJSON()),main:this.mainIndex}}static fromJSON(e){if(!e||!Array.isArray(e.ranges)||typeof e.main!="number"||e.main>=e.ranges.length)throw new RangeError("Invalid JSON representation for EditorSelection");return new n(e.ranges.map(t=>Lt.fromJSON(t)),e.main)}static single(e,t=e){return new n([n.range(e,t)],0)}static create(e,t=0){if(e.length==0)throw new RangeError("A selection needs at least one range");for(let i=0,s=0;ss.from-r.from),t=e.indexOf(i);for(let s=1;sr.head?n.range(a,l):n.range(l,a))}}return new n(e,t)}};function Ua(n,e){for(let t of n.ranges)if(t.to>e)throw new RangeError("Selection points outside of document")}var Er=0,C=class n{constructor(e,t,i,s,r){this.combine=e,this.compareInput=t,this.compare=i,this.isStatic=s,this.id=Er++,this.default=e([]),this.extensions=typeof r=="function"?r(this):r}get reader(){return this}static define(e={}){return new n(e.combine||(t=>t),e.compareInput||((t,i)=>t===i),e.compare||(e.combine?(t,i)=>t===i:Rr),!!e.static,e.enables)}of(e){return new ni([],this,0,e)}compute(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new ni(e,this,1,t)}computeN(e,t){if(this.isStatic)throw new Error("Can't compute a static facet");return new ni(e,this,2,t)}from(e,t){return t||(t=i=>i),this.compute([e],i=>t(i.field(e)))}};function Rr(n,e){return n==e||n.length==e.length&&n.every((t,i)=>t===e[i])}var ni=class{constructor(e,t,i,s){this.dependencies=e,this.facet=t,this.type=i,this.value=s,this.id=Er++}dynamicSlot(e){var t;let i=this.value,s=this.facet.compareInput,r=this.id,o=e[r]>>1,l=this.type==2,a=!1,h=!1,c=[];for(let f of this.dependencies)f=="doc"?a=!0:f=="selection"?h=!0:(((t=e[f.id])!==null&&t!==void 0?t:1)&1)==0&&c.push(e[f.id]);return{create(f){return f.values[o]=i(f),1},update(f,u){if(a&&u.docChanged||h&&(u.docChanged||u.selection)||Sr(f,c)){let d=i(f);if(l?!za(d,f.values[o],s):!s(d,f.values[o]))return f.values[o]=d,1}return 0},reconfigure:(f,u)=>{let d,p=u.config.address[r];if(p!=null){let m=Fn(u,p);if(this.dependencies.every(g=>g instanceof C?u.facet(g)===f.facet(g):g instanceof J?u.field(g,!1)==f.field(g,!1):!0)||(l?za(d=i(f),m,s):s(d=i(f),m)))return f.values[o]=m,0}else d=i(f);return f.values[o]=d,1}}}get extension(){return this}};function za(n,e,t){if(n.length!=e.length)return!1;for(let i=0;in[a.id]),s=t.map(a=>a.type),r=i.filter(a=>!(a&1)),o=n[e.id]>>1;function l(a){let h=[];for(let c=0;ci===s),e);return e.provide&&(t.provides=e.provide(t)),t}create(e){let t=e.facet(Dn).find(i=>i.field==this);return(t?.create||this.createF)(e)}slot(e){let t=e[this.id]>>1;return{create:i=>(i.values[t]=this.create(i),1),update:(i,s)=>{let r=i.values[t],o=this.updateF(r,s);return this.compareF(r,o)?0:(i.values[t]=o,1)},reconfigure:(i,s)=>{let r=i.facet(Dn),o=s.facet(Dn),l;return(l=r.find(a=>a.field==this))&&l!=o.find(a=>a.field==this)?(i.values[t]=l.create(i),1):s.config.address[this.id]!=null?(i.values[t]=s.field(this),0):(i.values[t]=this.create(i),1)}}}init(e){return[this,Dn.of({field:this,create:e})]}get extension(){return this}},Et={lowest:4,low:3,default:2,high:1,highest:0};function Bi(n){return e=>new Pn(e,n)}var Oe={highest:Bi(Et.highest),high:Bi(Et.high),default:Bi(Et.default),low:Bi(Et.low),lowest:Bi(Et.lowest)},Pn=class{constructor(e,t){this.inner=e,this.prec=t}get extension(){return this}},In=class n{of(e){return new Ei(this,e)}reconfigure(e){return n.reconfigure.of({compartment:this,extension:e})}get(e){return e.config.compartments.get(this)}},Ei=class{constructor(e,t){this.compartment=e,this.inner=t}get extension(){return this}},Nn=class n{constructor(e,t,i,s,r,o){for(this.base=e,this.compartments=t,this.dynamicSlots=i,this.address=s,this.staticValues=r,this.facets=o,this.statusTemplate=[];this.statusTemplate.length>1]}static resolve(e,t,i){let s=[],r=Object.create(null),o=new Map;for(let u of $d(e,t,o))u instanceof J?s.push(u):(r[u.facet.id]||(r[u.facet.id]=[])).push(u);let l=Object.create(null),a=[],h=[];for(let u of s)l[u.id]=h.length<<1,h.push(d=>u.slot(d));let c=i?.config.facets;for(let u in r){let d=r[u],p=d[0].facet,m=c&&c[u]||[];if(d.every(g=>g.type==0))if(l[p.id]=a.length<<1|1,Rr(m,d))a.push(i.facet(p));else{let g=p.combine(d.map(y=>y.value));a.push(i&&p.compare(g,i.facet(p))?i.facet(p):g)}else{for(let g of d)g.type==0?(l[g.id]=a.length<<1|1,a.push(g.value)):(l[g.id]=h.length<<1,h.push(y=>g.dynamicSlot(y)));l[p.id]=h.length<<1,h.push(g=>Kd(g,p,d))}}let f=h.map(u=>u(l));return new n(e,o,f,l,a,r)}};function $d(n,e,t){let i=[[],[],[],[],[]],s=new Map;function r(o,l){let a=s.get(o);if(a!=null){if(a<=l)return;let h=i[a].indexOf(o);h>-1&&i[a].splice(h,1),o instanceof Ei&&t.delete(o.compartment)}if(s.set(o,l),Array.isArray(o))for(let h of o)r(h,l);else if(o instanceof Ei){if(t.has(o.compartment))throw new RangeError("Duplicate use of compartment in extensions");let h=e.get(o.compartment)||o.inner;t.set(o.compartment,h),r(h,l)}else if(o instanceof Pn)r(o.inner,o.prec);else if(o instanceof J)i[l].push(o),o.provides&&r(o.provides,l);else if(o instanceof ni)i[l].push(o),o.facet.extensions&&r(o.facet.extensions,Et.default);else{let h=o.extension;if(!h)throw new Error(`Unrecognized extension value in extension set (${o}).`);if(h==o)throw new Error(`Unrecognized extension value in extension set (${o}). This sometimes happens because multiple instances of @codemirror/state are loaded, breaking instanceof checks.`);r(h,l)}}return r(n,Et.default),i.reduce((o,l)=>o.concat(l))}function Li(n,e){if(e&1)return 2;let t=e>>1,i=n.status[t];if(i==4)throw new Error("Cyclic dependency between fields and/or facets");if(i&2)return i;n.status[t]=4;let s=n.computeSlot(n,n.config.dynamicSlots[t]);return n.status[t]=2|s}function Fn(n,e){return e&1?n.config.staticValues[e>>1]:n.values[e>>1]}var Ga=C.define(),Cr=C.define({combine:n=>n.some(e=>e),static:!0}),_a=C.define({combine:n=>n.length?n[0]:void 0,static:!0}),Ya=C.define(),Ja=C.define(),Xa=C.define(),Qa=C.define({combine:n=>n.length?n[0]:!1}),ke=class{constructor(e,t){this.type=e,this.value=t}static define(){return new Ar}},Ar=class{of(e){return new ke(this,e)}},Mr=class{constructor(e){this.map=e}of(e){return new L(this,e)}},L=class n{constructor(e,t){this.type=e,this.value=t}map(e){let t=this.type.map(this.value,e);return t===void 0?void 0:t==this.value?this:new n(this.type,t)}is(e){return this.type==e}static define(e={}){return new Mr(e.map||(t=>t))}static mapEffects(e,t){if(!e.length)return e;let i=[];for(let s of e){let r=s.map(t);r&&i.push(r)}return i}};L.reconfigure=L.define();L.appendConfig=L.define();var ie=class n{constructor(e,t,i,s,r,o){this.startState=e,this.changes=t,this.selection=i,this.effects=s,this.annotations=r,this.scrollIntoView=o,this._doc=null,this._state=null,i&&Ua(i,t.newLength),r.some(l=>l.type==n.time)||(this.annotations=r.concat(n.time.of(Date.now())))}static create(e,t,i,s,r,o){return new n(e,t,i,s,r,o)}get newDoc(){return this._doc||(this._doc=this.changes.apply(this.startState.doc))}get newSelection(){return this.selection||this.startState.selection.map(this.changes)}get state(){return this._state||this.startState.applyTransaction(this),this._state}annotation(e){for(let t of this.annotations)if(t.type==e)return t.value}get docChanged(){return!this.changes.empty}get reconfigured(){return this.startState.config!=this.state.config}isUserEvent(e){let t=this.annotation(n.userEvent);return!!(t&&(t==e||t.length>e.length&&t.slice(0,e.length)==e&&t[e.length]=="."))}};ie.time=ke.define();ie.userEvent=ke.define();ie.addToHistory=ke.define();ie.remote=ke.define();function jd(n,e){let t=[];for(let i=0,s=0;;){let r,o;if(i=n[i]))r=n[i++],o=n[i++];else if(s=0;s--){let r=i[s](n);r instanceof ie?n=r:Array.isArray(r)&&r.length==1&&r[0]instanceof ie?n=r[0]:n=eh(e,si(r),!1)}return n}function Gd(n){let e=n.startState,t=e.facet(Xa),i=n;for(let s=t.length-1;s>=0;s--){let r=t[s](n);r&&Object.keys(r).length&&(i=Za(i,Tr(e,r,n.changes.newLength),!0))}return i==n?n:ie.create(e,n.changes,n.selection,i.effects,i.annotations,i.scrollIntoView)}var _d=[];function si(n){return n==null?_d:Array.isArray(n)?n:[n]}var K=(function(n){return n[n.Word=0]="Word",n[n.Space=1]="Space",n[n.Other=2]="Other",n})(K||(K={})),Yd=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/,Dr;try{Dr=new RegExp("[\\p{Alphabetic}\\p{Number}_]","u")}catch{}function Jd(n){if(Dr)return Dr.test(n);for(let e=0;e"\x80"&&(t.toUpperCase()!=t.toLowerCase()||Yd.test(t)))return!0}return!1}function Xd(n){return e=>{if(!/\S/.test(e))return K.Space;if(Jd(e))return K.Word;for(let t=0;t-1)return K.Word;return K.Other}}var j=class n{constructor(e,t,i,s,r,o){this.config=e,this.doc=t,this.selection=i,this.values=s,this.status=e.statusTemplate.slice(),this.computeSlot=r,o&&(o._state=this);for(let l=0;ls.set(h,a)),t=null),s.set(l.value.compartment,l.value.extension)):l.is(L.reconfigure)?(t=null,i=l.value):l.is(L.appendConfig)&&(t=null,i=si(i).concat(l.value));let r;t?r=e.startState.values.slice():(t=Nn.resolve(i,s,this),r=new n(t,this.doc,this.selection,t.dynamicSlots.map(()=>null),(a,h)=>h.reconfigure(a,this),null).values);let o=e.startState.facet(Cr)?e.newSelection:e.newSelection.asSingle();new n(t,e.newDoc,o,r,(l,a)=>a.update(l,e),e)}replaceSelection(e){return typeof e=="string"&&(e=this.toText(e)),this.changeByRange(t=>({changes:{from:t.from,to:t.to,insert:e},range:b.cursor(t.from+e.length)}))}changeByRange(e){let t=this.selection,i=e(t.ranges[0]),s=this.changes(i.changes),r=[i.range],o=si(i.effects);for(let l=1;lo.spec.fromJSON(l,a)))}}return n.create({doc:e.doc,selection:b.fromJSON(e.selection),extensions:t.extensions?s.concat([t.extensions]):s})}static create(e={}){let t=Nn.resolve(e.extensions||[],new Map),i=e.doc instanceof F?e.doc:F.of((e.doc||"").split(t.staticFacet(n.lineSeparator)||wr)),s=e.selection?e.selection instanceof b?e.selection:b.single(e.selection.anchor,e.selection.head):b.single(0);return Ua(s,i.length),t.staticFacet(Cr)||(s=s.asSingle()),new n(t,i,s,t.dynamicSlots.map(()=>null),(r,o)=>o.create(r),null)}get tabSize(){return this.facet(n.tabSize)}get lineBreak(){return this.facet(n.lineSeparator)||`
+`}get readOnly(){return this.facet(Qa)}phrase(e,...t){for(let i of this.facet(n.phrases))if(Object.prototype.hasOwnProperty.call(i,e)){e=i[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(i,s)=>{if(s=="$")return"$";let r=+(s||1);return!r||r>t.length?i:t[r-1]})),e}languageDataAt(e,t,i=-1){let s=[];for(let r of this.facet(Ga))for(let o of r(this,t,i))Object.prototype.hasOwnProperty.call(o,e)&&s.push(o[e]);return s}charCategorizer(e){let t=this.languageDataAt("wordChars",e);return Xd(t.length?t[0]:"")}wordAt(e){let{text:t,from:i,length:s}=this.doc.lineAt(e),r=this.charCategorizer(e),o=e-i,l=e-i;for(;o>0;){let a=te(t,o,!1);if(r(t.slice(a,o))!=K.Word)break;o=a}for(;ln.length?n[0]:4});j.lineSeparator=_a;j.readOnly=Qa;j.phrases=C.define({compare(n,e){let t=Object.keys(n),i=Object.keys(e);return t.length==i.length&&t.every(s=>n[s]==e[s])}});j.languageData=Ga;j.changeFilter=Ya;j.transactionFilter=Ja;j.transactionExtender=Xa;In.reconfigure=L.define();function ce(n,e,t={}){let i={};for(let s of n)for(let r of Object.keys(s)){let o=s[r],l=i[r];if(l===void 0)i[r]=o;else if(!(l===o||o===void 0))if(Object.hasOwnProperty.call(t,r))i[r]=t[r](l,o);else throw new Error("Config merge conflict for field "+r)}for(let s in e)i[s]===void 0&&(i[s]=e[s]);return i}var Pe=class{eq(e){return this==e}range(e,t=e){return Ri.create(e,t,this)}};Pe.prototype.startSide=Pe.prototype.endSide=0;Pe.prototype.point=!1;Pe.prototype.mapMode=se.TrackDel;function Pr(n,e){return n==e||n.constructor==e.constructor&&n.eq(e)}var Ri=class n{constructor(e,t,i){this.from=e,this.to=t,this.value=i}static create(e,t,i){return new n(e,t,i)}};function Or(n,e){return n.from-e.from||n.value.startSide-e.value.startSide}var Br=class n{constructor(e,t,i,s){this.from=e,this.to=t,this.value=i,this.maxPoint=s}get length(){return this.to[this.to.length-1]}findIndex(e,t,i,s=0){let r=i?this.to:this.from;for(let o=s,l=r.length;;){if(o==l)return o;let a=o+l>>1,h=r[a]-e||(i?this.value[a].endSide:this.value[a].startSide)-t;if(a==o)return h>=0?o:l;h>=0?l=a:o=a+1}}between(e,t,i,s){for(let r=this.findIndex(t,-1e9,!0),o=this.findIndex(i,1e9,!1,r);rd||u==d&&h.startSide>0&&h.endSide<=0)continue;(d-u||h.endSide-h.startSide)<0||(o<0&&(o=u),h.point&&(l=Math.max(l,d-u)),i.push(h),s.push(u-o),r.push(d-o))}return{mapped:i.length?new n(s,r,i,l):null,pos:o}}},I=class n{constructor(e,t,i,s){this.chunkPos=e,this.chunk=t,this.nextLayer=i,this.maxPoint=s}static create(e,t,i,s){return new n(e,t,i,s)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:i=!1,filterFrom:s=0,filterTo:r=this.length}=e,o=e.filter;if(t.length==0&&!o)return this;if(i&&(t=t.slice().sort(Or)),this.isEmpty)return t.length?n.of(t):this;let l=new Hn(this,null,-1).goto(0),a=0,h=[],c=new Se;for(;l.value||a=0){let f=t[a++];c.addInner(f.from,f.to,f.value)||h.push(f)}else l.rangeIndex==1&&l.chunkIndexthis.chunkEnd(l.chunkIndex)||rl.to||r=r&&e<=r+o.length&&o.between(r,e-r,t-r,i)===!1)return}this.nextLayer.between(e,t,i)}}iter(e=0){return Pi.from([this]).goto(e)}get isEmpty(){return this.nextLayer==this}static iter(e,t=0){return Pi.from(e).goto(t)}static compare(e,t,i,s,r=-1){let o=e.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),l=t.filter(f=>f.maxPoint>0||!f.isEmpty&&f.maxPoint>=r),a=qa(o,l,i),h=new Rt(o,a,r),c=new Rt(l,a,r);i.iterGaps((f,u,d)=>Ka(h,f,c,u,d,s)),i.empty&&i.length==0&&Ka(h,0,c,0,0,s)}static eq(e,t,i=0,s){s==null&&(s=999999999);let r=e.filter(c=>!c.isEmpty&&t.indexOf(c)<0),o=t.filter(c=>!c.isEmpty&&e.indexOf(c)<0);if(r.length!=o.length)return!1;if(!r.length)return!0;let l=qa(r,o),a=new Rt(r,l,0).goto(i),h=new Rt(o,l,0).goto(i);for(;;){if(a.to!=h.to||!Lr(a.active,h.active)||a.point&&(!h.point||!Pr(a.point,h.point)))return!1;if(a.to>s)return!0;a.next(),h.next()}}static spans(e,t,i,s,r=-1){let o=new Rt(e,null,r).goto(t),l=t,a=o.openStart;for(;;){let h=Math.min(o.to,i);if(o.point){let c=o.activeForPoint(o.to),f=o.pointFroml&&(s.span(l,h,o.active,a),a=o.openEnd(h));if(o.to>i)return a+(o.point&&o.to>i?1:0);l=o.to,o.next()}}static of(e,t=!1){let i=new Se;for(let s of e instanceof Ri?[e]:t?Qd(e):e)i.add(s.from,s.to,s.value);return i.finish()}static join(e){if(!e.length)return n.empty;let t=e[e.length-1];for(let i=e.length-2;i>=0;i--)for(let s=e[i];s!=n.empty;s=s.nextLayer)t=new n(s.chunkPos,s.chunk,t,Math.max(s.maxPoint,t.maxPoint));return t}};I.empty=new I([],[],null,-1);function Qd(n){if(n.length>1)for(let e=n[0],t=1;t0)return n.slice().sort(Or);e=i}return n}I.empty.nextLayer=I.empty;var Se=class n{finishChunk(e){this.chunks.push(new Br(this.from,this.to,this.value,this.maxPoint)),this.chunkPos.push(this.chunkStart),this.chunkStart=-1,this.setMaxPoint=Math.max(this.setMaxPoint,this.maxPoint),this.maxPoint=-1,e&&(this.from=[],this.to=[],this.value=[])}constructor(){this.chunks=[],this.chunkPos=[],this.chunkStart=-1,this.last=null,this.lastFrom=-1e9,this.lastTo=-1e9,this.from=[],this.to=[],this.value=[],this.maxPoint=-1,this.setMaxPoint=-1,this.nextLayer=null}add(e,t,i){this.addInner(e,t,i)||(this.nextLayer||(this.nextLayer=new n)).add(e,t,i)}addInner(e,t,i){let s=e-this.lastTo||i.startSide-this.last.endSide;if(s<=0&&(e-this.lastFrom||i.startSide-this.last.startSide)<0)throw new Error("Ranges must be added sorted by `from` position and `startSide`");return s<0?!1:(this.from.length==250&&this.finishChunk(!0),this.chunkStart<0&&(this.chunkStart=e),this.from.push(e-this.chunkStart),this.to.push(t-this.chunkStart),this.last=i,this.lastFrom=e,this.lastTo=t,this.value.push(i),i.point&&(this.maxPoint=Math.max(this.maxPoint,t-e)),!0)}addChunk(e,t){if((e-this.lastTo||t.value[0].startSide-this.last.endSide)<0)return!1;this.from.length&&this.finishChunk(!0),this.setMaxPoint=Math.max(this.setMaxPoint,t.maxPoint),this.chunks.push(t),this.chunkPos.push(e);let i=t.value.length-1;return this.last=t.value[i],this.lastFrom=t.from[i]+e,this.lastTo=t.to[i]+e,!0}finish(){return this.finishInner(I.empty)}finishInner(e){if(this.from.length&&this.finishChunk(!1),this.chunks.length==0)return e;let t=I.create(this.chunkPos,this.chunks,this.nextLayer?this.nextLayer.finishInner(e):e,this.setMaxPoint);return this.from=null,t}};function qa(n,e,t){let i=new Map;for(let r of n)for(let o=0;o=this.minPoint)break}}setRangeIndex(e){if(e==this.layer.chunk[this.chunkIndex].value.length){if(this.chunkIndex++,this.skip)for(;this.chunkIndex=i&&s.push(new Hn(o,t,i,r));return s.length==1?s[0]:new n(s)}get startSide(){return this.value?this.value.startSide:0}goto(e,t=-1e9){for(let i of this.heap)i.goto(e,t);for(let i=this.heap.length>>1;i>=0;i--)br(this.heap,i);return this.next(),this}forward(e,t){for(let i of this.heap)i.forward(e,t);for(let i=this.heap.length>>1;i>=0;i--)br(this.heap,i);(this.to-e||this.value.endSide-t)<0&&this.next()}next(){if(this.heap.length==0)this.from=this.to=1e9,this.value=null,this.rank=-1;else{let e=this.heap[0];this.from=e.from,this.to=e.to,this.value=e.value,this.rank=e.rank,e.value&&e.next(),br(this.heap,0)}}};function br(n,e){for(let t=n[e];;){let i=(e<<1)+1;if(i>=n.length)break;let s=n[i];if(i+1=0&&(s=n[i+1],i++),t.compare(s)<0)break;n[i]=t,n[e]=s,e=i}}var Rt=class{constructor(e,t,i){this.minPoint=i,this.active=[],this.activeTo=[],this.activeRank=[],this.minActive=-1,this.point=null,this.pointFrom=0,this.pointRank=0,this.to=-1e9,this.endSide=0,this.openStart=-1,this.cursor=Pi.from(e,t,i)}goto(e,t=-1e9){return this.cursor.goto(e,t),this.active.length=this.activeTo.length=this.activeRank.length=0,this.minActive=-1,this.to=e,this.endSide=t,this.openStart=-1,this.next(),this}forward(e,t){for(;this.minActive>-1&&(this.activeTo[this.minActive]-e||this.active[this.minActive].endSide-t)<0;)this.removeActive(this.minActive);this.cursor.forward(e,t)}removeActive(e){On(this.active,e),On(this.activeTo,e),On(this.activeRank,e),this.minActive=$a(this.active,this.activeTo)}addActive(e){let t=0,{value:i,to:s,rank:r}=this.cursor;for(;t0;)t++;Bn(this.active,t,i),Bn(this.activeTo,t,s),Bn(this.activeRank,t,r),e&&Bn(e,t,this.cursor.from),this.minActive=$a(this.active,this.activeTo)}next(){let e=this.to,t=this.point;this.point=null;let i=this.openStart<0?[]:null;for(;;){let s=this.minActive;if(s>-1&&(this.activeTo[s]-this.cursor.from||this.active[s].endSide-this.cursor.startSide)<0){if(this.activeTo[s]>e){this.to=this.activeTo[s],this.endSide=this.active[s].endSide;break}this.removeActive(s),i&&On(i,s)}else if(this.cursor.value)if(this.cursor.from>e){this.to=this.cursor.from,this.endSide=this.cursor.startSide;break}else{let r=this.cursor.value;if(!r.point)this.addActive(i),this.cursor.next();else if(t&&this.cursor.to==this.to&&this.cursor.from=0&&i[s]=0&&!(this.activeRank[i]e||this.activeTo[i]==e&&this.active[i].endSide>=this.point.endSide)&&t.push(this.active[i]);return t.reverse()}openEnd(e){let t=0;for(let i=this.activeTo.length-1;i>=0&&this.activeTo[i]>e;i--)t++;return t}};function Ka(n,e,t,i,s,r){n.goto(e),t.goto(i);let o=i+s,l=i,a=i-e,h=!!r.boundChange;for(let c=!1;;){let f=n.to+a-t.to,u=f||n.endSide-t.endSide,d=u<0?n.to+a:t.to,p=Math.min(d,o);if(n.point||t.point?(n.point&&t.point&&Pr(n.point,t.point)&&Lr(n.activeForPoint(n.to),t.activeForPoint(t.to))||r.comparePoint(l,p,n.point,t.point),c=!1):(c&&r.boundChange(l),p>l&&!Lr(n.active,t.active)&&r.compareRange(l,p,n.active,t.active),h&&po)break;l=d,u<=0&&n.next(),u>=0&&t.next()}}function Lr(n,e){if(n.length!=e.length)return!1;for(let t=0;t=e;i--)n[i+1]=n[i];n[e]=t}function $a(n,e){let t=-1,i=1e9;for(let s=0;s=e)return s;if(s==n.length)break;r+=n.charCodeAt(s)==9?t-r%t:1,s=te(n,s)}return i===!0?-1:n.length}var th=typeof Symbol>"u"?"__\u037C":Symbol.for("\u037C"),Ir=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),ih=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{},Ie=class{constructor(e,t){this.rules=[];let{finish:i}=t||{};function s(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function r(o,l,a,h){let c=[],f=/^@(\w+)\b/.exec(o[0]),u=f&&f[1]=="keyframes";if(f&&l==null)return a.push(o[0]+";");for(let d in l){let p=l[d];if(/&/.test(d))r(d.split(/,\s*/).map(m=>o.map(g=>m.replace(/&/,g))).reduce((m,g)=>m.concat(g)),p,a);else if(p&&typeof p=="object"){if(!f)throw new RangeError("The value of a property ("+d+") should be a primitive value.");r(s(d),p,c,u)}else p!=null&&c.push(d.replace(/_.*/,"").replace(/[A-Z]/g,m=>"-"+m.toLowerCase())+": "+p+";")}(c.length||u)&&a.push((i&&!f&&!h?o.map(i):o).join(", ")+" {"+c.join(" ")+"}")}for(let o in e)r(s(o),e[o],this.rules)}getRules(){return this.rules.join(`
+`)}static newName(){let e=ih[th]||1;return ih[th]=e+1,"\u037C"+e.toString(36)}static mount(e,t,i){let s=e[Ir],r=i&&i.nonce;s?r&&s.setNonce(r):s=new Nr(e,r),s.mount(Array.isArray(t)?t:[t],e)}},nh=new Map,Nr=class{constructor(e,t){let i=e.ownerDocument||e,s=i.defaultView;if(!e.head&&e.adoptedStyleSheets&&s.CSSStyleSheet){let r=nh.get(i);if(r)return e[Ir]=r;this.sheet=new s.CSSStyleSheet,nh.set(i,this)}else this.styleTag=i.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);this.modules=[],e[Ir]=this}mount(e,t){let i=this.sheet,s=0,r=0;for(let o=0;o-1&&(this.modules.splice(a,1),r--,a=-1),a==-1){if(this.modules.splice(r++,0,l),i)for(let h=0;h",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},Zd=typeof navigator<"u"&&/Mac/.test(navigator.platform),ep=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(ne=0;ne<10;ne++)lt[48+ne]=lt[96+ne]=String(ne);var ne;for(ne=1;ne<=24;ne++)lt[ne+111]="F"+ne;var ne;for(ne=65;ne<=90;ne++)lt[ne]=String.fromCharCode(ne+32),oi[ne]=String.fromCharCode(ne);var ne;for(Vn in lt)oi.hasOwnProperty(Vn)||(oi[Vn]=lt[Vn]);var Vn;function sh(n){var e=Zd&&n.metaKey&&n.shiftKey&&!n.ctrlKey&&!n.altKey||ep&&n.shiftKey&&n.key&&n.key.length==1||n.key=="Unidentified",t=!e&&n.key||(n.shiftKey?oi:lt)[n.keyCode]||n.key||"Unidentified";return t=="Esc"&&(t="Escape"),t=="Del"&&(t="Delete"),t=="Left"&&(t="ArrowLeft"),t=="Up"&&(t="ArrowUp"),t=="Right"&&(t="ArrowRight"),t=="Down"&&(t="ArrowDown"),t}function W(){var n=arguments[0];typeof n=="string"&&(n=document.createElement(n));var e=1,t=arguments[1];if(t&&typeof t=="object"&&t.nodeType==null&&!Array.isArray(t)){for(var i in t)if(Object.prototype.hasOwnProperty.call(t,i)){var s=t[i];typeof s=="string"?n.setAttribute(i,s):s!=null&&(n[i]=s)}e++}for(;e2),D={mac:ah||/Mac/.test(be.platform),windows:/Win/.test(be.platform),linux:/Linux|X11/.test(be.platform),ie:xs,ie_version:$h?Ur.documentMode||6:_r?+_r[1]:Gr?+Gr[1]:0,gecko:oh,gecko_version:oh?+(/Firefox\/(\d+)/.exec(be.userAgent)||[0,0])[1]:0,chrome:!!Fr,chrome_version:Fr?+Fr[1]:0,ios:ah,android:/Android\b/.test(be.userAgent),webkit:lh,webkit_version:lh?+(/\bAppleWebKit\/(\d+)/.exec(be.userAgent)||[0,0])[1]:0,safari:Yr,safari_version:Yr?+(/\bVersion\/(\d+(\.\d+)?)/.exec(be.userAgent)||[0,0])[1]:0,tabSize:Ur.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};function qo(n,e){for(let t in n)t=="class"&&e.class?e.class+=" "+n.class:t=="style"&&e.style?e.style+=";"+n.style:e[t]=n[t];return e}var ts=Object.create(null);function Ko(n,e,t){if(n==e)return!0;n||(n=ts),e||(e=ts);let i=Object.keys(n),s=Object.keys(e);if(i.length-(t&&i.indexOf(t)>-1?1:0)!=s.length-(t&&s.indexOf(t)>-1?1:0))return!1;for(let r of i)if(r!=t&&(s.indexOf(r)==-1||n[r]!==e[r]))return!1;return!0}function tp(n,e){for(let t=n.attributes.length-1;t>=0;t--){let i=n.attributes[t].name;e[i]==null&&n.removeAttribute(i)}for(let t in e){let i=e[t];t=="style"?n.style.cssText=i:n.getAttribute(t)!=i&&n.setAttribute(t,i)}}function hh(n,e,t){let i=!1;if(e)for(let s in e)t&&s in t||(i=!0,s=="style"?n.style.cssText="":n.removeAttribute(s));if(t)for(let s in t)e&&e[s]==t[s]||(i=!0,s=="style"?n.style.cssText=t[s]:n.setAttribute(s,t[s]));return i}function ip(n){let e=Object.create(null);for(let t=0;t0?3e8:-4e8:t>0?1e8:-1e8,new Wt(e,t,t,i,e.widget||null,!1)}static replace(e){let t=!!e.block,i,s;if(e.isBlockGap)i=-5e8,s=4e8;else{let{start:r,end:o}=jh(e,t);i=(r?t?-3e8:-1:5e8)-1,s=(o?t?2e8:1:-6e8)+1}return new Wt(e,i,s,t,e.widget||null,!0)}static line(e){return new Yi(e)}static set(e,t=!1){return I.of(e,t)}hasHeight(){return this.widget?this.widget.estimatedHeight>-1:!1}};B.none=I.empty;var _i=class n extends B{constructor(e){let{start:t,end:i}=jh(e);super(t?-1:5e8,i?1:-6e8,null,e),this.tagName=e.tagName||"span",this.attrs=e.class&&e.attributes?qo(e.attributes,{class:e.class}):e.class?{class:e.class}:e.attributes||ts}eq(e){return this==e||e instanceof n&&this.tagName==e.tagName&&Ko(this.attrs,e.attrs)}range(e,t=e){if(e>=t)throw new RangeError("Mark decorations may not be empty");return super.range(e,t)}};_i.prototype.point=!1;var Yi=class n extends B{constructor(e){super(-2e8,-2e8,null,e)}eq(e){return e instanceof n&&this.spec.class==e.spec.class&&Ko(this.spec.attributes,e.spec.attributes)}range(e,t=e){if(t!=e)throw new RangeError("Line decoration ranges must be zero-length");return super.range(e,t)}};Yi.prototype.mapMode=se.TrackBefore;Yi.prototype.point=!0;var Wt=class n extends B{constructor(e,t,i,s,r,o){super(t,i,r,e),this.block=s,this.isReplace=o,this.mapMode=s?t<=0?se.TrackBefore:se.TrackAfter:se.TrackDel}get type(){return this.startSide!=this.endSide?fe.WidgetRange:this.startSide<=0?fe.WidgetBefore:fe.WidgetAfter}get heightRelevant(){return this.block||!!this.widget&&(this.widget.estimatedHeight>=5||this.widget.lineBreaks>0)}eq(e){return e instanceof n&&np(this.widget,e.widget)&&this.block==e.block&&this.startSide==e.startSide&&this.endSide==e.endSide}range(e,t=e){if(this.isReplace&&(e>t||e==t&&this.startSide>0&&this.endSide<=0))throw new RangeError("Invalid range for replacement decoration");if(!this.isReplace&&t!=e)throw new RangeError("Widget decorations can only have zero-length ranges");return super.range(e,t)}};Wt.prototype.point=!0;function jh(n,e=!1){let{inclusiveStart:t,inclusiveEnd:i}=n;return t==null&&(t=n.inclusive),i==null&&(i=n.inclusive),{start:t??e,end:i??e}}function np(n,e){return n==e||!!(n&&e&&n.compare(e))}function ui(n,e,t,i=0){let s=t.length-1;s>=0&&t[s]+i>=n?t[s]=Math.max(t[s],e):t.push(n,e)}var is=class n extends Pe{constructor(e,t,i){super(),this.tagName=e,this.attributes=t,this.rank=i}eq(e){return e==this||e instanceof n&&this.tagName==e.tagName&&Ko(this.attributes,e.attributes)}static create(e){return new n(e.tagName,e.attributes||ts,e.rank==null?50:Math.max(0,Math.min(e.rank,100)))}static set(e,t=!1){return I.of(e,t)}};is.prototype.startSide=is.prototype.endSide=-1;function Ji(n){let e;return n.nodeType==11?e=n.getSelection?n:n.ownerDocument:e=n,e.getSelection()}function Jr(n,e){return e?n==e||n.contains(e.nodeType!=1?e.parentNode:e):!1}function Wi(n,e){if(!e.anchorNode)return!1;try{return Jr(n,e.anchorNode)}catch{return!1}}function Jn(n){return n.nodeType==3?Xi(n,0,n.nodeValue.length).getClientRects():n.nodeType==1?n.getClientRects():[]}function Vi(n,e,t,i){return t?ch(n,e,t,i,-1)||ch(n,e,t,i,1):!1}function xt(n){for(var e=0;;e++)if(n=n.previousSibling,!n)return e}function ns(n){return n.nodeType==1&&/^(DIV|P|LI|UL|OL|BLOCKQUOTE|DD|DT|H\d|SECTION|PRE)$/.test(n.nodeName)}function ch(n,e,t,i,s){for(;;){if(n==t&&e==i)return!0;if(e==(s<0?0:ct(n))){if(n.nodeName=="DIV")return!1;let r=n.parentNode;if(!r||r.nodeType!=1)return!1;e=xt(n)+(s<0?0:1),n=r}else if(n.nodeType==1){if(n=n.childNodes[e+(s<0?-1:0)],n.nodeType==1&&n.contentEditable=="false")return!1;e=s<0?ct(n):0}else return!1}}function ct(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function ss(n,e){let{left:t,right:i}=n;if(t==i)return n;let s=e?t:i;return{left:s,right:s,top:n.top,bottom:n.bottom}}function sp(n){let e=n.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.innerWidth,top:0,bottom:n.innerHeight}}function Uh(n,e){let t=e.width/n.offsetWidth,i=e.height/n.offsetHeight;return(t>.995&&t<1.005||!isFinite(t)||Math.abs(e.width-n.offsetWidth)<1)&&(t=1),(i>.995&&i<1.005||!isFinite(i)||Math.abs(e.height-n.offsetHeight)<1)&&(i=1),{scaleX:t,scaleY:i}}function rp(n,e,t,i,s,r,o,l){let a=n.ownerDocument,h=a.defaultView||window;for(let c=n,f=!1;c&&!f;)if(c.nodeType==1){let u,d=c==a.body,p=1,m=1;if(d)u=sp(h);else{if(/^(fixed|sticky)$/.test(getComputedStyle(c).position)&&(f=!0),c.scrollHeight<=c.clientHeight&&c.scrollWidth<=c.clientWidth){c=c.assignedSlot||c.parentNode;continue}let x=c.getBoundingClientRect();({scaleX:p,scaleY:m}=Uh(c,x)),u={left:x.left,right:x.left+c.clientWidth*p,top:x.top,bottom:x.top+c.clientHeight*m}}let g=0,y=0;if(s=="nearest")e.top0&&e.bottom>u.bottom+y&&(y=e.bottom-u.bottom+o)):e.bottom>u.bottom-o&&(y=e.bottom-u.bottom+o,t<0&&e.top-y0&&e.right>u.right+g&&(g=e.right-u.right+r)):e.right>u.right-r&&(g=e.right-u.right+r,t<0&&e.leftu.bottom||e.leftu.right)&&(e={left:Math.max(e.left,u.left),right:Math.min(e.right,u.right),top:Math.max(e.top,u.top),bottom:Math.min(e.bottom,u.bottom)}),c=c.assignedSlot||c.parentNode}else if(c.nodeType==11)c=c.host;else break}function Gh(n,e=!0){let t=n.ownerDocument,i=null,s=null;for(let r=n.parentNode;r&&!(r==t.body||(!e||i)&&s);)if(r.nodeType==1)!s&&r.scrollHeight>r.clientHeight&&(s=r),e&&!i&&r.scrollWidth>r.clientWidth&&(i=r),r=r.assignedSlot||r.parentNode;else if(r.nodeType==11)r=r.host;else break;return{x:i,y:s}}var Xr=class{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}eq(e){return this.anchorNode==e.anchorNode&&this.anchorOffset==e.anchorOffset&&this.focusNode==e.focusNode&&this.focusOffset==e.focusOffset}setRange(e){let{anchorNode:t,focusNode:i}=e;this.set(t,Math.min(e.anchorOffset,t?ct(t):0),i,Math.min(e.focusOffset,i?ct(i):0))}set(e,t,i,s){this.anchorNode=e,this.anchorOffset=t,this.focusNode=i,this.focusOffset=s}},Nt=null;D.safari&&D.safari_version>=26&&(Nt=!1);function _h(n){if(n.setActive)return n.setActive();if(Nt)return n.focus(Nt);let e=[];for(let t=n;t&&(e.push(t,t.scrollTop,t.scrollLeft),t!=t.ownerDocument);t=t.parentNode);if(n.focus(Nt==null?{get preventScroll(){return Nt={preventScroll:!0},!0}}:void 0),!Nt){Nt=!1;for(let t=0;tMath.max(0,n.document.documentElement.scrollHeight-n.innerHeight-4):n.scrollTop>Math.max(1,n.scrollHeight-n.clientHeight-4)}function Jh(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&i>0)return{node:t,offset:i};if(t.nodeType==1&&i>0){if(t.contentEditable=="false")return null;t=t.childNodes[i-1],i=ct(t)}else if(t.parentNode&&!ns(t))i=xt(t),t=t.parentNode;else return null}}function Xh(n,e){for(let t=n,i=e;;){if(t.nodeType==3&&i=t){if(l.level==i)return o;(r<0||(s!=0?s<0?l.fromt:e[r].level>l.level))&&(r=o)}}if(r<0)throw new RangeError("Index out of range");return r}};function ec(n,e){if(n.length!=e.length)return!1;for(let t=0;t=0;m-=3)if(Xe[m+1]==-d){let g=Xe[m+2],y=g&2?s:g&4?g&1?r:s:0;y&&(U[f]=U[Xe[m]]=y),l=m;break}}else{if(Xe.length==189)break;Xe[l++]=f,Xe[l++]=u,Xe[l++]=a}else if((p=U[f])==2||p==1){let m=p==s;a=m?0:1;for(let g=l-3;g>=0;g-=3){let y=Xe[g+2];if(y&2)break;if(m)Xe[g+2]|=2;else{if(y&4)break;Xe[g+2]|=4}}}}}function dp(n,e,t,i){for(let s=0,r=i;s<=t.length;s++){let o=s?t[s-1].to:n,l=sa;)p==g&&(p=t[--m].from,g=m?t[m-1].to:n),U[--p]=d;a=c}else r=h,a++}}}function Zr(n,e,t,i,s,r,o){let l=i%2?2:1;if(i%2==s%2)for(let a=e,h=0;aa&&o.push(new He(a,m.from,d));let g=m.direction==Vt!=!(d%2);eo(n,g?i+1:i,s,m.inner,m.from,m.to,o),a=m.to}p=m.to}else{if(p==t||(c?U[p]!=l:U[p]==l))break;p++}u?Zr(n,a,p,i+1,s,u,o):ae;){let c=!0,f=!1;if(!h||a>r[h-1].to){let m=U[a-1];m!=l&&(c=!1,f=m==16)}let u=!c&&l==1?[]:null,d=c?i:i+1,p=a;e:for(;;)if(h&&p==r[h-1].to){if(f)break e;let m=r[--h];if(!c)for(let g=m.from,y=h;;){if(g==e)break e;if(y&&r[y-1].to==g)g=r[--y].from;else{if(U[g-1]==l)break e;break}}if(u)u.push(m);else{m.toU.length;)U[U.length]=256;let i=[],s=e==Vt?0:1;return eo(n,s,s,t,0,n.length,i),i}function tc(n){return[new He(0,n,0)]}var ic="";function mp(n,e,t,i,s){var r;let o=i.head-n.from,l=He.find(e,o,(r=i.bidiLevel)!==null&&r!==void 0?r:-1,i.assoc),a=e[l],h=a.side(s,t);if(o==h){let u=l+=s?1:-1;if(u<0||u>=e.length)return null;a=e[l=u],o=a.side(!s,t),h=a.side(s,t)}let c=te(n.text,o,a.forward(s,t));(ca.to)&&(c=h),ic=n.text.slice(Math.min(o,c),Math.max(o,c));let f=l==(s?e.length-1:0)?null:e[l+(s?1:-1)];return f&&c==h&&f.level+(s?0:1)n.some(e=>e)}),cc=C.define({combine:n=>n.some(e=>e)}),fc=C.define(),zi=class n{constructor(e,t,i,s,r,o=!1){this.range=e,this.y=t,this.x=i,this.yMargin=s,this.xMargin=r,this.isSnapshot=o}map(e){return e.empty?this:new n(this.range.map(e),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}clip(e){return this.range.to<=e.doc.length?this:new n(b.cursor(e.doc.length),this.y,this.x,this.yMargin,this.xMargin,this.isSnapshot)}},zn=L.define({map:(n,e)=>n.map(e)}),uc=L.define();function re(n,e,t){let i=n.facet(oc);i.length?i[0](e):window.onerror&&window.onerror(String(e),t,void 0,void 0,e)||(t?console.error(t+":",e):console.error(e))}var at=C.define({combine:n=>n.length?n[0]:!0}),yp=0,ai=C.define({combine(n){return n.filter((e,t)=>{for(let i=0;i{let a=[];return o&&a.push(ws.of(h=>{let c=h.plugin(l);return c?o(c):B.none})),r&&a.push(r(l)),a})}static fromClass(e,t){return n.define((i,s)=>new e(i,s),t)}},qi=class{constructor(e){this.spec=e,this.mustUpdate=null,this.value=null}get plugin(){return this.spec&&this.spec.plugin}update(e){if(this.value){if(this.mustUpdate){let t=this.mustUpdate;if(this.mustUpdate=null,this.value.update)try{this.value.update(t)}catch(i){if(re(t.state,i,"CodeMirror plugin crashed"),this.value.destroy)try{this.value.destroy()}catch{}this.deactivate()}}}else if(this.spec)try{this.value=this.spec.plugin.create(e,this.spec.arg)}catch(t){re(e.state,t,"CodeMirror plugin crashed"),this.deactivate()}return this}destroy(e){var t;if(!((t=this.value)===null||t===void 0)&&t.destroy)try{this.value.destroy()}catch(i){re(e.state,i,"CodeMirror plugin crashed")}}deactivate(){this.spec=this.value=null}},dc=C.define(),Go=C.define(),ws=C.define(),pc=C.define(),_o=C.define(),Qi=C.define(),mc=C.define();function uh(n,e){let t=n.state.facet(mc);if(!t.length)return t;let i=t.map(r=>r instanceof Function?r(n):r),s=[];return I.spans(i,e.from,e.to,{point(){},span(r,o,l,a){let h=r-e.from,c=o-e.from,f=s;for(let u=l.length-1;u>=0;u--,a--){let d=l[u].spec.bidiIsolate,p;if(d==null&&(d=gp(e.text,h,c)),a>0&&f.length&&(p=f[f.length-1]).to==h&&p.direction==d)p.to=c,f=p.inner;else{let m={from:h,to:c,direction:d,inner:[]};f.push(m),f=m.inner}}}}),s}var gc=C.define();function Yo(n){let e=0,t=0,i=0,s=0;for(let r of n.state.facet(gc)){let o=r(n);o&&(o.left!=null&&(e=Math.max(e,o.left)),o.right!=null&&(t=Math.max(t,o.right)),o.top!=null&&(i=Math.max(i,o.top)),o.bottom!=null&&(s=Math.max(s,o.bottom)))}return{left:e,right:t,top:i,bottom:s}}var Ni=C.define(),Ke=class n{constructor(e,t,i,s){this.fromA=e,this.toA=t,this.fromB=i,this.toB=s}join(e){return new n(Math.min(this.fromA,e.fromA),Math.max(this.toA,e.toA),Math.min(this.fromB,e.fromB),Math.max(this.toB,e.toB))}addToSet(e){let t=e.length,i=this;for(;t>0;t--){let s=e[t-1];if(!(s.fromA>i.toA)){if(s.toAs.push(new Ke(r,o,l,a))),this.changedRanges=s}static create(e,t,i){return new n(e,t,i)}get viewportChanged(){return(this.flags&4)>0}get viewportMoved(){return(this.flags&8)>0}get heightChanged(){return(this.flags&2)>0}get geometryChanged(){return this.docChanged||(this.flags&18)>0}get focusChanged(){return(this.flags&1)>0}get docChanged(){return!this.changes.empty}get selectionSet(){return this.transactions.some(e=>e.selection)}get empty(){return this.flags==0&&this.transactions.length==0}},bp=[],X=class{constructor(e,t,i=0){this.dom=e,this.length=t,this.flags=i,this.parent=null,e.cmTile=this}get breakAfter(){return this.flags&1}get children(){return bp}isWidget(){return!1}get isHidden(){return!1}isComposite(){return!1}isLine(){return!1}isText(){return!1}isBlock(){return!1}get domAttrs(){return null}sync(e){if(this.flags|=2,this.flags&4){this.flags&=-5;let t=this.domAttrs;t&&tp(this.dom,t)}}toString(){return this.constructor.name+(this.children.length?`(${this.children})`:"")+(this.breakAfter?"#":"")}destroy(){this.parent=null}setDOM(e){this.dom=e,e.cmTile=this}get posAtStart(){return this.parent?this.parent.posBefore(this):0}get posAtEnd(){return this.posAtStart+this.length}posBefore(e,t=this.posAtStart){let i=t;for(let s of this.children){if(s==e)return i;i+=s.length+s.breakAfter}throw new RangeError("Invalid child in posBefore")}posAfter(e){return this.posBefore(e)+e.length}covers(e){return!0}coordsIn(e,t,i){return null}domPosFor(e,t){let i=xt(this.dom),s=this.length?e>0:t>0;return new Qe(this.parent.dom,i+(s?1:0),e==0||e==this.length)}markDirty(e){this.flags&=-3,e&&(this.flags|=4),this.parent&&this.parent.flags&2&&this.parent.markDirty(!1)}get overrideDOMText(){return null}get root(){for(let e=this;e;e=e.parent)if(e instanceof mi)return e;return null}static get(e){return e.cmTile}},pi=class extends X{constructor(e){super(e,0),this._children=[]}isComposite(){return!0}get children(){return this._children}get lastChild(){return this.children.length?this.children[this.children.length-1]:null}append(e){this.children.push(e),e.parent=this}sync(e){if(this.flags&2)return;super.sync(e);let t=this.dom,i=null,s,r=e?.node==t?e:null,o=0;for(let l of this.children){if(l.sync(e),o+=l.length+l.breakAfter,s=i?i.nextSibling:t.firstChild,r&&s!=l.dom&&(r.written=!0),l.dom.parentNode==t)for(;s&&s!=l.dom;)s=dh(s);else t.insertBefore(l.dom,s);i=l.dom}for(s=i?i.nextSibling:t.firstChild,r&&s&&(r.written=!0);s;)s=dh(s);this.length=o}};function dh(n){let e=n.nextSibling;return n.parentNode.removeChild(n),e}var mi=class extends pi{constructor(e,t){super(t),this.view=e}owns(e){for(;e;e=e.parent)if(e==this)return!0;return!1}isBlock(){return!0}nearest(e){for(;;){if(!e)return null;let t=X.get(e);if(t&&this.owns(t))return t;e=e.parentNode}}blockTiles(e){for(let t=[],i=this,s=0,r=0;;)if(s==i.children.length){if(!t.length)return;i=i.parent,i.breakAfter&&r++,s=t.pop()}else{let o=i.children[s++];if(o instanceof ht)t.push(s),i=o,s=0;else{let l=r+o.length,a=e(o,r);if(a!==void 0)return a;r=l+o.breakAfter}}}resolveBlock(e,t){let i,s=-1,r,o=-1;if(this.blockTiles((l,a)=>{let h=a+l.length;if(e>=a&&e<=h){if(l.isWidget()&&t>=-1&&t<=1){if(l.flags&32)return!0;l.flags&16&&(i=void 0)}(ae||e==a&&(t>1?l.length:l.covers(-1)))&&(!r||!l.isWidget()&&r.isWidget())&&(r=l,o=e-a)}}),!i&&!r)throw new Error("No tile at position "+e);return i&&t<0||!r?{tile:i,offset:s}:{tile:r,offset:o}}},ht=class n extends pi{constructor(e,t){super(e),this.wrapper=t}isBlock(){return!0}covers(e){return this.children.length?e<0?this.children[0].covers(-1):this.lastChild.covers(1):!1}get domAttrs(){return this.wrapper.attributes}static of(e,t){let i=new n(t||document.createElement(e.tagName),e);return t||(i.flags|=4),i}},gi=class n extends pi{constructor(e,t){super(e),this.attrs=t}isLine(){return!0}static start(e,t,i){let s=new n(t||document.createElement("div"),e);return(!t||!i)&&(s.flags|=4),s}get domAttrs(){return this.attrs}resolveInline(e,t,i){let s=null,r=-1,o=null,l=-1;function a(c,f){for(let u=0,d=0;u=f&&(p.isComposite()?a(p,f-d):(!o||o.isHidden&&(t>0&&!(o.flags&32)||i&&wp(o,p)))&&(m>f||p.flags&32)?(o=p,l=f-d):(ds&&(e=s);let r=e,o=e,l=0;e==0&&t<0||e==s&&t>=0?D.chrome||D.gecko||(e?(r--,l=1):o=0)?0:a.length-1];return D.safari&&!l&&h.width==0&&(h=Array.prototype.find.call(a,c=>c.width)||h),i==null?h:ss(h,(l?l>0:t<0)==i)}static of(e,t){let i=new n(t||document.createTextNode(e),e);return t||(i.flags|=2),i}},zt=class n extends X{constructor(e,t,i,s){super(e,t,s),this.widget=i}isWidget(){return!0}get isHidden(){return this.widget.isHidden}covers(e){return this.flags&48?!1:(this.flags&(e<0?64:128))>0}coordsIn(e,t){return this.coordsInWidget(e,t,!1)}coordsInWidget(e,t,i){let s=this.widget.coordsAt(this.dom,e,t);if(s)return s;if(i)return ss(this.dom.getBoundingClientRect(),this.length?e==0:t<=0);{let r=this.dom.getClientRects(),o=null;if(!r.length)return null;let l=this.flags&16?!0:this.flags&32?!1:e>0;for(let a=l?r.length-1:0;o=r[a],!(e>0?a==0:a==r.length-1||o.top0==i)}},io=class{constructor(e){this.index=0,this.beforeBreak=!1,this.parents=[],this.tile=e}advance(e,t,i){let{tile:s,index:r,beforeBreak:o,parents:l}=this;for(;e||t>0;)if(s.isComposite())if(o){if(!e)break;i&&i.break(),e--,o=!1}else if(r==s.children.length){if(!e&&!l.length)break;i&&i.leave(s),o=!!s.breakAfter,{tile:s,index:r}=l.pop(),r++}else{let a=s.children[r],h=a.breakAfter;(t>0?a.length<=e:a.length=0;l--){let a=t.marks[l],h=s.lastChild;if(h instanceof Ce&&h.mark.eq(a.mark))h.dom!=a.dom&&h.setDOM(Hr(a.dom)),s=h;else{if(this.cache.reused.get(a)){let f=X.get(a.dom);f&&f.setDOM(Hr(a.dom))}let c=Ce.of(a.mark,a.dom);s.append(c),s=c}this.cache.reused.set(a,2)}let r=X.get(e.text);r&&this.cache.reused.set(r,2);let o=new Ft(e.text,e.text.nodeValue);o.flags|=8,this.pos=e.range.toB,s.append(o)}addInlineWidget(e,t,i){let s=this.afterWidget&&e.flags&48&&(this.afterWidget.flags&48)==(e.flags&48);s||this.flushBuffer();let r=this.ensureMarks(t,i);!s&&!(e.flags&16)&&r.append(this.getBuffer(1)),r.append(e),this.pos+=e.length,this.afterWidget=e}addMark(e,t,i){this.flushBuffer(),this.ensureMarks(t,i).append(e),this.pos+=e.length,this.afterWidget=null}addBlockWidget(e){this.getBlockPos().append(e),this.pos+=e.length,this.lastBlock=e,this.endLine()}continueWidget(e){let t=this.afterWidget||this.lastBlock;t.length+=e,this.pos+=e}addLineStart(e,t){var i;e||(e=yc);let s=gi.start(e,t||((i=this.cache.find(gi))===null||i===void 0?void 0:i.dom),!!t);this.getBlockPos().append(this.lastBlock=this.curLine=s)}addLine(e){this.getBlockPos().append(e),this.pos+=e.length,this.lastBlock=e,this.endLine()}addBreak(){this.lastBlock.flags|=1,this.endLine(),this.pos++}addLineStartIfNotCovered(e){this.blockPosCovered()||this.addLineStart(e)}ensureLine(e){this.curLine||this.addLineStart(e)}ensureMarks(e,t){var i;let s=this.curLine;for(let r=e.length-1;r>=0;r--){let o=e[r],l;if(t>0&&(l=s.lastChild)&&l instanceof Ce&&l.mark.eq(o))s=l,t--;else{let a=Ce.of(o,(i=this.cache.find(Ce,h=>h.mark.eq(o)))===null||i===void 0?void 0:i.dom);s.append(a),s=a,t=0}}return s}endLine(){if(this.curLine){this.flushBuffer();let e=this.curLine.lastChild;(!e||!ph(this.curLine,!1)||e.dom.nodeName!="BR"&&e.isWidget()&&!(D.ios&&ph(this.curLine,!0)))&&this.curLine.append(this.cache.findWidget(Wr,0,32)||new zt(Wr.toDOM(),0,Wr,32)),this.curLine=this.afterWidget=null}}updateBlockWrappers(){this.wrapperPos>this.pos+1e4&&(this.blockWrappers.goto(this.pos),this.wrappers.length=0);for(let e=this.wrappers.length-1;e>=0;e--)this.wrappers[e].to=this.pos){let t=e.rank*102+e.value.rank,i=new no(e.from,e.to,e.value,t),s=this.wrappers.length;for(;s>0&&(this.wrappers[s-1].rank-i.rank||this.wrappers[s-1].to-i.to)<0;)s--;this.wrappers.splice(s,0,i)}this.wrapperPos=this.pos}getBlockPos(){var e;this.updateBlockWrappers();let t=this.root;for(let i of this.wrappers){let s=t.lastChild;if(i.fromo.wrapper.eq(i.wrapper)))===null||e===void 0?void 0:e.dom);t.append(r),t=r}}return t}blockPosCovered(){let e=this.lastBlock;return e!=null&&!e.breakAfter&&(!e.isWidget()||(e.flags&160)>0)}getBuffer(e){let t=2|(e<0?16:32),i=this.cache.find(yi,void 0,1);return i&&(i.flags=t),i||new yi(t)}flushBuffer(){this.afterWidget&&!(this.afterWidget.flags&32)&&(this.afterWidget.parent.append(this.getBuffer(-1)),this.afterWidget=null)}},ro=class{constructor(e){this.skipCount=0,this.text="",this.textOff=0,this.cursor=e.iter()}skip(e){this.textOff+e<=this.text.length?this.textOff+=e:(this.skipCount+=e-(this.text.length-this.textOff),this.text="",this.textOff=0)}next(e){if(this.textOff==this.text.length){let{value:s,lineBreak:r,done:o}=this.cursor.next(this.skipCount);if(this.skipCount=0,o)throw new Error("Ran out of text content when drawing inline views");this.text=s;let l=this.textOff=Math.min(e,s.length);return r?null:s.slice(0,l)}let t=Math.min(this.text.length,this.textOff+e),i=this.text.slice(this.textOff,t);return this.textOff=t,i}},os=[zt,gi,Ft,Ce,yi,ht,mi];for(let n=0;n[]),this.index=os.map(()=>0),this.reused=new Map}add(e){let t=e.constructor.bucket,i=this.buckets[t];i.length<6?i.push(e):i[this.index[t]=(this.index[t]+1)%6]=e}find(e,t,i=2){let s=e.bucket,r=this.buckets[s],o=this.index[s];for(let l=0;l{if(this.cache.add(o),o.isComposite())return!1},enter:o=>this.cache.add(o),leave:()=>{},break:()=>{}}}run(e,t){let i=t&&this.getCompositionContext(t.text);for(let s=0,r=0,o=0;;){let l=os){let h=a-s;this.preserve(h,!o,!l),s=a,r+=h}if(!l)break;t&&l.fromA<=t.range.fromA&&l.toA>=t.range.toA?(this.forward(l.fromA,t.range.fromA,t.range.fromA{if(o.isWidget())if(this.openWidget)this.builder.continueWidget(a-l);else{let h=a>0||l{o.isLine()?this.builder.addLineStart(o.attrs,this.cache.maybeReuse(o)):(this.cache.add(o),o instanceof Ce&&s.unshift(o.mark)),this.openWidget=!1},leave:o=>{o.isLine()?s.length&&(s.length=r=0):o instanceof Ce&&(s.shift(),r=Math.min(r,s.length))},break:()=>{this.builder.addBreak(),this.openWidget=!1}}),this.text.skip(e)}emit(e,t){let i=null,s=this.builder,r=-1,o=I.spans(this.decorations,e,t,{point:(l,a,h,c,f,u)=>{if(h instanceof Wt){if(this.disallowBlockEffectsFor[u]){if(h.block)throw new RangeError("Block decorations may not be specified via plugins");if(a>this.view.state.doc.lineAt(l).to)throw new RangeError("Decorations that replace line breaks may not be specified via plugins")}if(r=c.length,f>c.length)s.continueWidget(a-l);else{let d=h.widget||(h.block?wt.block:wt.inline),p=vp(h),m=this.cache.findWidget(d,a-l,p)||zt.of(d,this.view,a-l,p);h.block?(h.startSide>0&&s.addLineStartIfNotCovered(i),s.addBlockWidget(m)):(s.ensureLine(i),s.addInlineWidget(m,c,f))}i=null}else i=kp(i,h);a>l&&this.text.skip(a-l)},span:(l,a,h,c)=>{for(let f=l;f-1&&(this.openWidget=o>r),this.openWidget||s.addLineStartIfNotCovered(i),this.openMarks=o}forward(e,t,i=1){t-e<=10?this.old.advance(t-e,i,this.reuseWalker):(this.old.advance(5,-1,this.reuseWalker),this.old.advance(t-e-10,-1),this.old.advance(5,i,this.reuseWalker))}getCompositionContext(e){let t=[],i=null;for(let s=e.parentNode;;s=s.parentNode){let r=X.get(s);if(s==this.view.contentDOM)break;r instanceof Ce?t.push(r):r?.isLine()?i=r:r instanceof ht||(s.nodeName=="DIV"&&!i&&s!=this.view.contentDOM?i=new gi(s,yc):i||t.push(Ce.of(new _i({tagName:s.nodeName.toLowerCase(),attributes:ip(s)}),s)))}return{line:i,marks:t}}};function ph(n,e){let t=i=>{for(let s of i.children)if((e?s.isText():s.length)||t(s))return!0;return!1};return t(n)}function vp(n){let e=n.isReplace?(n.startSide<0?64:0)|(n.endSide>0?128:0):n.startSide>0?32:16;return n.block&&(e|=256),e}var yc={class:"cm-line"};function kp(n,e){let t=e.spec.attributes,i=e.spec.class;return!t&&!i||(n||(n={class:"cm-line"}),t&&qo(t,n),i&&(n.class+=" "+i)),n}function Sp(n){let e=[];for(let t=n.parents.length;t>1;t--){let i=t==n.parents.length?n.tile:n.parents[t].tile;i instanceof Ce&&e.push(i.mark)}return e}function Hr(n){let e=X.get(n);return e&&e.setDOM(n.cloneNode()),n}var wt=class extends xe{constructor(e){super(),this.tag=e}eq(e){return e.tag==this.tag}toDOM(){return document.createElement(this.tag)}updateDOM(e){return e.nodeName.toLowerCase()==this.tag}get isHidden(){return!0}};wt.inline=new wt("span");wt.block=new wt("div");var Wr=new class extends xe{toDOM(){return document.createElement("br")}get isHidden(){return!0}get editable(){return!0}},ls=class{constructor(e){this.view=e,this.decorations=[],this.blockWrappers=[],this.dynamicDecorationMap=[!1],this.domChanged=null,this.hasComposition=null,this.editContextFormatting=B.none,this.lastCompositionAfterCursor=!1,this.minWidth=0,this.minWidthFrom=0,this.minWidthTo=0,this.impreciseAnchor=null,this.impreciseHead=null,this.forceSelection=!1,this.lastUpdate=Date.now(),this.updateDeco(),this.tile=new mi(e,e.contentDOM),this.updateInner([new Ke(0,0,0,e.state.doc.length)],null)}update(e){var t;let i=e.changedRanges;this.minWidth>0&&i.length&&(i.every(({fromA:c,toA:f})=>fthis.minWidthTo)?(this.minWidthFrom=e.changes.mapPos(this.minWidthFrom,1),this.minWidthTo=e.changes.mapPos(this.minWidthTo,1)):this.minWidth=this.minWidthFrom=this.minWidthTo=0),this.updateEditContextFormatting(e);let s=-1;this.view.inputState.composing>=0&&!this.view.observer.editContext&&(!((t=this.domChanged)===null||t===void 0)&&t.newSel?s=this.domChanged.newSel.head:!Lp(e.changes,this.hasComposition)&&!e.selectionSet&&(s=e.state.selection.main.head));let r=s>-1?Ap(this.view,e.changes,s):null;if(this.domChanged=null,this.hasComposition){let{from:c,to:f}=this.hasComposition;i=new Ke(c,f,e.changes.mapPos(c,-1),e.changes.mapPos(f,1)).addToSet(i.slice())}this.hasComposition=r?{from:r.range.fromB,to:r.range.toB}:null,(D.ie||D.chrome)&&!r&&e&&e.state.doc.lines!=e.startState.doc.lines&&(this.forceSelection=!0);let o=this.decorations,l=this.blockWrappers;this.updateDeco();let a=Dp(o,this.decorations,e.changes);a.length&&(i=Ke.extendWithRanges(i,a));let h=Op(l,this.blockWrappers,e.changes);return h.length&&(i=Ke.extendWithRanges(i,h)),r&&!i.some(c=>c.fromA<=r.range.fromA&&c.toA>=r.range.toA)&&(i=r.range.addToSet(i.slice())),this.tile.flags&2&&i.length==0?!1:(this.updateInner(i,r),e.transactions.length&&(this.lastUpdate=Date.now()),!0)}updateInner(e,t){this.view.viewState.mustMeasureContent=!0;let{observer:i}=this.view;i.ignore(()=>{if(t||e.length){let o=this.tile,l=new lo(this.view,o,this.blockWrappers,this.decorations,this.dynamicDecorationMap);t&&X.get(t.text)&&l.cache.reused.set(X.get(t.text),2),this.tile=l.run(e,t),ao(o,l.cache.reused)}this.tile.dom.style.height=this.view.viewState.contentHeight/this.view.scaleY+"px",this.tile.dom.style.flexBasis=this.minWidth?this.minWidth+"px":"";let r=D.chrome||D.ios?{node:i.selectionRange.focusNode,written:!1}:void 0;this.tile.sync(r),r&&(r.written||i.selectionRange.focusNode!=r.node||!this.tile.dom.contains(r.node))&&(this.forceSelection=!0),this.tile.dom.style.height=""});let s=[];if(this.view.viewport.from||this.view.viewport.to-1)&&Wi(i,this.view.observer.selectionRange)&&!(s&&i.contains(s));if(!(r||t||o))return;let l=this.forceSelection;this.forceSelection=!1;let a=this.view.state.selection.main,h,c;if(a.empty?c=h=this.inlineDOMNearPos(a.anchor,a.assoc||1):(c=this.inlineDOMNearPos(a.head,a.head==a.from?1:-1),h=this.inlineDOMNearPos(a.anchor,a.anchor==a.from?1:-1)),D.gecko&&a.empty&&!this.hasComposition&&Cp(h)){let u=document.createTextNode("");this.view.observer.ignore(()=>h.node.insertBefore(u,h.node.childNodes[h.offset]||null)),h=c=new Qe(u,0),l=!0}let f=this.view.observer.selectionRange;(l||!f.focusNode||(!Vi(h.node,h.offset,f.anchorNode,f.anchorOffset)||!Vi(c.node,c.offset,f.focusNode,f.focusOffset))&&!this.suppressWidgetCursorChange(f,a))&&(this.view.observer.ignore(()=>{D.android&&D.chrome&&i.contains(f.focusNode)&&Bp(f.focusNode,i)&&(i.blur(),i.focus({preventScroll:!0}));let u=Ji(this.view.root);if(u)if(a.empty){if(D.gecko){let d=Mp(h.node,h.offset);if(d&&d!=3){let p=(d==1?Jh:Xh)(h.node,h.offset);p&&(h=new Qe(p.node,p.offset))}}u.collapse(h.node,h.offset),a.bidiLevel!=null&&u.caretBidiLevel!==void 0&&(u.caretBidiLevel=a.bidiLevel)}else if(u.extend){u.collapse(h.node,h.offset);try{u.extend(c.node,c.offset)}catch{}}else{let d=document.createRange();a.anchor>a.head&&([h,c]=[c,h]),d.setEnd(c.node,c.offset),d.setStart(h.node,h.offset),u.removeAllRanges(),u.addRange(d)}o&&this.view.root.activeElement==i&&(i.blur(),s&&s.focus())}),this.view.observer.setSelectionRange(h,c)),this.impreciseAnchor=h.precise?null:new Qe(f.anchorNode,f.anchorOffset),this.impreciseHead=c.precise?null:new Qe(f.focusNode,f.focusOffset)}suppressWidgetCursorChange(e,t){return this.hasComposition&&t.empty&&Vi(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset)&&this.posFromDOM(e.focusNode,e.focusOffset)==t.head}enforceCursorAssoc(){if(this.hasComposition)return;let{view:e}=this,t=e.state.selection.main,i=Ji(e.root),{anchorNode:s,anchorOffset:r}=e.observer.selectionRange;if(!i||!t.empty||!t.assoc||!i.modify)return;let o=this.lineAt(t.head,t.assoc);if(!o)return;let l=o.posAtStart;if(t.head==l||t.head==l+o.length)return;let a=this.coordsAt(t.head,-1),h=this.coordsAt(t.head,1);if(!a||!h||a.bottom>h.top)return;let c=this.domAtPos(t.head+t.assoc,t.assoc);i.collapse(c.node,c.offset),i.modify("move",t.assoc<0?"forward":"backward","lineboundary"),e.observer.readSelectionRange();let f=e.observer.selectionRange;e.docView.posFromDOM(f.anchorNode,f.anchorOffset)!=t.from&&i.collapse(s,r)}posFromDOM(e,t){let i=this.tile.nearest(e);if(!i)return this.tile.dom.compareDocumentPosition(e)&2?0:this.view.state.doc.length;let s=i.posAtStart;if(i.isComposite()){let r;if(e==i.dom)r=i.dom.childNodes[t];else{let o=ct(e)==0?0:t==0?-1:1;for(;;){let l=e.parentNode;if(l==i.dom)break;o==0&&l.firstChild!=l.lastChild&&(e==l.firstChild?o=-1:o=1),e=l}o<0?r=e:r=e.nextSibling}if(r==i.dom.firstChild)return s;for(;r&&!X.get(r);)r=r.nextSibling;if(!r)return s+i.length;for(let o=0,l=s;;o++){let a=i.children[o];if(a.dom==r)return l;l+=a.length+a.breakAfter}}else return i.isText()?e==i.dom?s+t:s+(t?i.length:0):s}domAtPos(e,t){let{tile:i,offset:s}=this.tile.resolveBlock(e,t);return i.isWidget()?i.domPosFor(s,t):i.domIn(s,t)}inlineDOMNearPos(e,t){let i,s=-1,r=!1,o,l=-1,a=!1;return this.tile.blockTiles((h,c)=>{if(h.isWidget()){if(h.flags&32&&c>=e)return!0;h.flags&16&&(r=!0)}else{let f=c+h.length;if(c<=e&&(i=h,s=e-c,r=f=e&&!o&&(o=h,l=e-c,a=c>e),c>e&&o)return!0}}),!i&&!o?this.domAtPos(e,t):(r&&o?i=null:a&&i&&(o=null),i&&t<0||!o?i.domIn(s,t):o.domIn(l,t))}coordsAt(e,t,i){let{tile:s,offset:r}=this.tile.resolveBlock(e,t);return s.isWidget()?s.widget instanceof Ki?null:s.coordsInWidget(r,t,!0):s.coordsIn(r,t,i)}lineAt(e,t){let{tile:i}=this.tile.resolveBlock(e,t);return i.isLine()?i:null}coordsForChar(e){let{tile:t,offset:i}=this.tile.resolveBlock(e,1);if(!t.isLine())return null;function s(r,o){if(r.isComposite())for(let l of r.children){if(l.length>=o){let a=s(l,o);if(a)return a}if(o-=l.length,o<0)break}else if(r.isText()&&oMath.max(this.view.scrollDOM.clientWidth,this.minWidth)+1,l=-1,a=this.view.textDirection==V.LTR,h=0,c=(f,u,d)=>{for(let p=0;ps);p++){let m=f.children[p],g=u+m.length,y=m.dom.getBoundingClientRect(),{height:x}=y;if(d&&!p&&(h+=y.top-d.top),m instanceof ht)g>i&&c(m,u,y);else if(u>=i&&(h>0&&t.push(-h),t.push(x+h),h=0,o)){let w=m.dom.lastChild,O=w?Jn(w):[];if(O.length){let v=O[O.length-1],k=a?v.right-y.left:y.right-v.left;k>l&&(l=k,this.minWidth=r,this.minWidthFrom=u,this.minWidthTo=g)}}d&&p==f.children.length-1&&(h+=d.bottom-y.bottom),u=g+m.breakAfter}};return c(this.tile,0,null),t}textDirectionAt(e){let{tile:t}=this.tile.resolveBlock(e,1);return getComputedStyle(t.dom).direction=="rtl"?V.RTL:V.LTR}measureTextSize(){let e=this.tile.blockTiles(o=>{if(o.isLine()&&o.children.length&&o.length<=20){let l=0,a;for(let h of o.children){if(!h.isText()||/[^ -~]/.test(h.text))return;let c=Jn(h.dom);if(c.length!=1)return;l+=c[0].width,a=c[0].height}if(l)return{lineHeight:o.dom.getBoundingClientRect().height,charWidth:l/o.length,textHeight:a}}});if(e)return e;let t=document.createElement("div"),i,s,r;return t.className="cm-line",t.style.width="99999px",t.style.position="absolute",t.textContent="abc def ghi jkl mno pqr stu",this.view.observer.ignore(()=>{this.tile.dom.appendChild(t);let o=Jn(t.firstChild)[0];i=t.getBoundingClientRect().height,s=o&&o.width?o.width/27:7,r=o&&o.height?o.height:i,t.remove()}),{lineHeight:i,charWidth:s,textHeight:r}}computeBlockGapDeco(){let e=[],t=this.view.viewState;for(let i=0,s=0;;s++){let r=s==t.viewports.length?null:t.viewports[s],o=r?r.from-1:this.view.state.doc.length;if(o>i){let l=(t.lineBlockAt(o).bottom-t.lineBlockAt(i).top)/this.view.scaleY;e.push(B.replace({widget:new Ki(l),block:!0,inclusive:!0,isBlockGap:!0}).range(i,o))}if(!r)break;i=r.to+1}return B.set(e)}updateDeco(){let e=1,t=this.view.state.facet(ws).map(r=>(this.dynamicDecorationMap[e++]=typeof r=="function")?r(this.view):r),i=!1,s=this.view.state.facet(_o).map((r,o)=>{let l=typeof r=="function";return l&&(i=!0),l?r(this.view):r});for(s.length&&(this.dynamicDecorationMap[e++]=i,t.push(I.join(s))),this.decorations=[this.editContextFormatting,...t,this.computeBlockGapDeco(),this.view.viewState.lineGapDeco];etypeof r=="function"?r(this.view):r)}scrollIntoView(e){if(e.isSnapshot){let h=this.view.viewState.lineBlockAt(e.range.head);this.view.scrollDOM.scrollTop=h.top-e.yMargin,this.view.scrollDOM.scrollLeft=e.xMargin;return}for(let h of this.view.state.facet(fc))try{if(h(this.view,e.range,e))return!0}catch(c){re(this.view.state,c,"scroll handler")}let{range:t}=e,i=this.coordsAt(t.head,t.assoc||(t.head>t.anchor?-1:1)),s;if(!i)return;!t.empty&&(s=this.coordsAt(t.anchor,t.anchor>t.head?-1:1))&&(i={left:Math.min(i.left,s.left),top:Math.min(i.top,s.top),right:Math.max(i.right,s.right),bottom:Math.max(i.bottom,s.bottom)});let r=Yo(this.view),o={left:i.left-r.left,top:i.top-r.top,right:i.right+r.right,bottom:i.bottom+r.bottom},{offsetWidth:l,offsetHeight:a}=this.view.scrollDOM;if(rp(this.view.scrollDOM,o,t.head