Download the PHP package jpmmartin/sylius-subscription-plugin without Composer
On this page you can find all versions of the php package jpmmartin/sylius-subscription-plugin. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Download jpmmartin/sylius-subscription-plugin
More information about jpmmartin/sylius-subscription-plugin
Files in jpmmartin/sylius-subscription-plugin
Package sylius-subscription-plugin
Short Description Product subscriptions for Sylius: plans, billing cycles and recurring orders
License MIT
Informations about the package sylius-subscription-plugin
Sylius Subscription Plugin
Sell products by subscription in a standard Sylius 2 storefront. A variant offers one or more plans; the customer buys it once or on a plan, in the same cart as anything else. The store can also offer a few frequencies of its own, with which a customer repeats their whole cart. The lines of an order that renew on the same interval make one subscription, so every renewal is one normal Sylius order with all of them, charged without the customer present, with retries when a charge is declined.
Features
- Plans per variant: interval (days, weeks, months or years), subscriber discount and an optional maximum of cycles, managed from the variant's Subscription tab in the admin.
- Introductory prices: a plan or a store frequency can take a discount of its own off the first order, or the first cycles, of each new subscription, before the subscriber price applies. See Introductory prices.
- Prepaid deliveries: a plan or a store frequency can charge a block of deliveries at once, three months paid in advance with one delivery a month for instance; the deliveries between charges are placed as orders of nothing. See Prepaid deliveries.
- Minimum commitment: a plan or a store frequency can commit its subscribers to a number of paid cycles before they can cancel or pause, as a better price's counterpart. See Minimum commitment.
- Free trials: a plan or a store frequency can start each new subscriber with some days free, once per customer and variant, with a payment method whose gateway keeps the card without charging it. See Free trials.
- Store frequencies and "Repeat this cart": the store defines its own frequencies (interval, discount, optional maximum of cycles and the channels that offer them) and marks the variants that can be repeated. On the cart page, and through the API, the customer repeats the cart with one of them: every one-time line of such a variant, including those added later, renews with it at the variant's price less its discount. Lines on a plan keep their plan; the rest are bought once. See Store frequencies and repeated carts.
- Mixed carts: the product page offers "one-time purchase" or a plan. A subscription line is never merged with a one-time line of the same variant, and it is priced at the variant's price less the plan's discount before promotions apply.
- Checkout rules, in the shop and in the API alike: an order with subscriptions needs a customer with an account, a payment method that can be charged without the customer, and the customer's consent to recurring charges. The consent text is versioned and stored as the customer saw it.
- Subscriptions of several products: an order starts one subscription per interval among its subscription lines, with an item per line (variant, quantity, frozen price, and plan or store frequency). Coffee and tea bought monthly in the same cart arrive together every month, in one order, whether each is on a plan or repeated with the store's monthly frequency.
- Lifecycle: a subscription starts pending when its order is placed and becomes active the day the order is paid. Cancelling that order before it is paid cancels it; once paid, only the customer or an administrator cancels it.
- Renewals: a console command processes the cycles that are due. Each cycle is checked by the store's gates, gets one completed renewal order with a line per item (frozen price on an immutable line; shipping, taxes and automatic promotions worked out as for any order), and is charged through a service the store can replace. An item whose variant cannot be sold that day (disabled, out of the channel or out of stock) is skipped in that cycle only, and the cycle says why. Declines are retried by a policy the store can configure or replace; an unknown outcome is reconciled instead of charged again. Running the command twice, or delivering its message twice, never charges a cycle twice.
- Failures never cancel: a cycle that cannot be charged fails, its unpaid order is cancelled and the next cycle is scheduled. Only a run of failed cycles suspends the subscription (three by default). An administrator can retry a failed cycle; cancelling a renewal order before it is paid skips that renewal.
- Customer account: the customer's subscriptions with their products, their renewals and what each skipped and where they are shipped; pausing and resuming them, skipping the next renewal, cancelling, changing how often and where they renew, changing their quantities, swapping a variant, removing a product or adding one, paying a renewal whose charge was declined or changing their card through the gateway, and recovering a subscription suspended because its renewals could not be charged. See Changing the address, Paying a declined renewal and Recovering a suspended subscription.
- Admin: a list filterable by state, customer, variant (of any of the products) and next renewal, and a page with the products, the consent, the failed renewals in a row, every renewal with what it took in or skipped and each charge attempt (date, outcome and reason), where it is shipped, and the actions the state allows: pause, resume, suspend, reactivate, cancel, skip the next renewal, change the frequency, change the address and retry a failed renewal.
- Price updates: an administrator reprices the existing subscriptions of a plan, a store frequency or a variant from today's catalogue. A decrease applies at once; an increase is announced with a configurable notice, and can be made to need the customer's acceptance. See Price updates.
- Shop API: besides the cart's operations, the signed-in customer's subscriptions with everything their account shows, and a PATCH for each action it offers. See Shop API.
- Committed cycles: a read-only query of what the active subscriptions of a variant will renew within a horizon, for planning stock.
- Events, no emails: the plugin tells no customer anything. It publishes an event of its own at every moment of a subscription's life, a renewal coming up and a declined charge that will be retried included, for the store to tell its customers as it sees fit. See Events.
Requirements
- Sylius 2.2, on PHP 8.2 to 8.5 and Symfony 6.4 or 7.4, or Sylius 2.3, on the PHP and Symfony it supports: 8.3 or later, and Symfony 6.4, 7.4 or 8.0.
- One of the databases Sylius tests its plugins against: MySQL 8.0 or 8.4, MariaDB 10.11 or 11.4, or
PostgreSQL 15, 16 or 17; MariaDB with Sylius 2.2 only, see Known limitations.
Up to 1.0.0, every one of them was checked on each change with Sylius 2.2; since then, the tests run
on PostgreSQL 16 and the migrations on MySQL 8.4, with Sylius 2.2 and 2.3, see
Continuous integration. On MySQL and MariaDB, codes are compared without
regard to case, as Sylius's own are:
MONTHLYandmonthlyare the same code there. On MariaDB, name it inserverVersion, as Doctrine asks (?serverVersion=mariadb-11.4.2): given a bare number, Doctrine takes MariaDB for MySQL and misreads column defaults when comparing schemas. - On MySQL and MariaDB, the plugin's tables are created in
utf8mb4withutf8mb4_unicode_ci, as Sylius's are, so their text takes any character, emojis included. The connection has to carry them too: setcharset: utf8mb4in your Doctrine connection if you want emojis anywhere, in Sylius's tables as in the plugin's. - The Symfony Workflow state machine adapter for Sylius's order graphs, which is Sylius 2's default:
the plugin reacts to the workflow events of
sylius_order_checkout,sylius_order_paymentandsylius_order. Its own graphs are always run by Symfony Workflow. - A payment gateway that can charge a stored payment method without the customer. See Charging renewals.
- Your own customer notices. The plugin sends no email, not even before charging a renewal. Listen to its events to tell your customers that a renewal is coming, that a charge was declined or that their subscription changed; charging without telling them is what brings chargebacks.
Installation
On a store with Symfony Flex, as Sylius Standard is, step 1 takes steps 2 to 4 for you, from the plugin's
recipe in symfony/recipes-contrib,
and prints what is left: naming your payment methods in step 3, and steps 5 to 7. A store without Flex, or
one that answered no when Composer offered the recipe, takes every step by hand; either way, the store
ends with the same files.
-
Require the plugin:
-
Register the bundle in
config/bundles.php, if Symfony Flex did not: -
Import its configuration and name the payment methods that may charge renewals, in
config/packages/jpm_martin_sylius_subscription.yaml. The recipe writes this file, with no method named: -
Import its routes in
config/routes/jpm_martin_sylius_subscription.yaml, which the recipe writes too. The admin routes go under the admin path, which puts them behind the admin firewall; the shop routes go under the locale prefix, exactly as Sylius's shop routes are imported, so the account's access control covers them: -
Let your order item carry the chosen plan, or the frequency its cart is repeated with: the class your store configures as
sylius_order.resources.order_item.classes.model, for example:Until it does, the plugin offers no product by subscription, refuses a plan or a frequency asked for through the API as not offered, and the admin's dashboard and subscriptions list say what is left: no line chosen as a subscription can become a one-off purchase instead.
-
Run the migrations. The plugin registers its own migrations namespace; its migrations, written with Doctrine's schema API rather than one platform's SQL, create its tables and the
subscription_plan_id,subscription_frequency_idandsubscription_trial_dayscolumns ofsylius_order_item: -
Process the due cycles on a schedule, with cron or Symfony Scheduler, as often as you want renewals to be charged. While step 5 is left, the command says so:
The command dispatches one
ProcessSubscriptionCyclemessage per due cycle onsylius.command_bus, so you may route that message to an asynchronous transport.
To take the plugin out, undo step 5 first: your order item would name the plugin's trait and interface,
which are gone once it is removed, and the container would not compile. Taking the plugin's migrations
down beforehand removes its tables and columns; composer remove then takes back the bundle and the two
files.
Configuration reference
Charging renewals
A renewal is charged through JpmMartin\SyliusSubscriptionPlugin\Payment\RenewalChargerInterface:
supports() says whether a payment method can be charged without the customer (checkout refuses
subscriptions with any other), charge() charges a payment, and status() asks what became of a
charge whose outcome was unknown, without charging again. Each answer is approved, declined (with the
issuer's reason), not attempted (with a reason) or unknown. A decline, or a refused charge, may also
carry the gateway's code for it, such as insufficient_funds: ChargeOutcome::declined($reason, $code). Each attempt keeps it, and the admin shows it next to the reason.
The default service
The default service supports the methods listed in payment_methods, and charges through Sylius's
payment requests: it creates a capture payment request for the renewal payment (a status one to
reconcile) and announces it, so the payment method's gateway handles it like any other payment
request. The gateway's handler must be able to capture that payment without the customer, typically
with a stored card or token; the outcome is read from the payment's state: completed is approved,
failed or cancelled is declined. The reason is read from the payment request's response data (the
first of reason, message, error or responsetext), and so is the code (the first of code,
decline_code or error_code).
None of the official gateway plugins checked reports a code yet: the Stripe plugin
(flux-se/sylius-stripe-plugin) writes only a reason when a payment fails, and the Adyen, Mollie and
PayPal plugins charge through Payum rather than payment requests. To retry by code, have your gateway's
handler put the code in the response data, or charge renewals with your own service.
- Synchronous or asynchronous payment requests. Sylius's own default transport for payment
requests is
sync://, and the outcome is then known at once. If your store routes payment requests to a worker, the charge is only queued: the plugin records an unknown outcome and, on the next runs, asks for the payment's status until the worker has settled it. It never charges the payment again. - Encryption. Sylius 2 encrypts payment requests. Generate the key once with
bin/console sylius:payment:generate-key. - A card the customer pays with must be kept. A customer may pay a declined renewal on the store's order payment page with another card (see Paying a declined renewal), and the next renewals are charged without them. The gateway's handler must keep that card for charges without the customer, as the checkout already requires of it; how depends on the gateway.
Your own service
Implement RenewalChargerInterface and point the interface's alias at your service, or redefine the
jpm_martin_sylius_subscription.payment.renewal_charger service. Everything in the plugin that
charges or checks a payment method goes through that alias.
Failed cycles and manual retries
A cycle fails when its retries run out, a gate rejects it, its hold expires or none of its items can be
sold that day. A failed cycle cancels its order if nothing was charged on it, which gives the reserved
stock back, and the next cycle is scheduled on the subscription's calendar: a failure never cancels a
subscription. After suspend_after_failed_cycles failed cycles in a row the subscription is suspended
instead, and generates no cycles until an administrator reactivates it, or, when the last of them failed
on a charge, its customer recovers it by paying; see
Recovering a suspended subscription.
An administrator can retry a failed cycle of an active or suspended subscription from its page. The retry places a new order with the items that can be sold now and charges it once, with no automatic retries; an unknown outcome is reconciled like any other. If it is paid, the cycle is paid without moving the calendar; if it is declined, the cycle stays failed and its new order is cancelled. The subscription's state does not change, except when the retry pays the last cycle its items had left: an active subscription then completes, and a suspended one completes when it is reactivated.
If an administrator cancels a renewal order before it is paid, its cycle is cancelled and the next one scheduled, without counting as a failure: that renewal is skipped.
Suspending a subscription cancels its open cycle but leaves alone a retry still awaiting the gateway's answer, which the scheduler keeps reconciling. Cancelling the subscription cancels that retry too.
A suspended or a paused subscription is taken up again on the first date of its calendar after that day, even when that date is the one of the cycle the suspension or the pause cancelled.
Pausing and skipping
A customer pauses an active subscription from their account, and resumes it when they want: a pause has no end date. An administrator can do both on the customer's behalf. A pause is not a suspension:
- pausing is the customer's, and so is resuming; a suspension, by an administrator or after failed cycles in a row, is lifted only by an administrator reactivating the subscription;
- pausing cancels the open cycle, and its order if nothing was charged on it, like a suspension, and leaves alone an administrator's retry still awaiting the gateway's answer;
- a paused subscription generates no cycles and no renewal notices;
- resuming schedules the next cycle on the first date of its calendar after that day, and keeps the failed cycles in a row: pausing changes nothing about the payment method, so it is no way around the suspension.
A customer, or an administrator on their behalf, can also skip the next renewal of an active
subscription while its order has not been placed: its cycle is cancelled, marked as skipped, and the
next cycle keeps its date on the calendar. A skipped renewal is neither a failure nor a charge, so it
does not use up a plan's maximum. Once the renewal's order is placed, for instance while its charge
awaits a retry, it can no longer be skipped. max_consecutive_skips limits how many renewals in a
row can be skipped: those skipped right before the open cycle, with no paid, failed or otherwise
cancelled cycle between them. With none set, there is no limit. A skip cannot be undone.
The skip is a service, SubscriptionRenewalSkipperInterface: replace it to let customers skip
another way.
A subscription within a minimum commitment cannot be paused by its customer, only skipped; see Minimum commitment. A prepaid delivery can be skipped and stays paid for; the charge of a prepaid block cannot, since it would skip its deliveries too: pause instead. See Prepaid deliveries.
Changing the address
A renewal goes to the addresses of the subscription's last order until they are changed. A customer changes them from their account, and an administrator from the subscription's page, for a subscription that is neither cancelled nor completed:
- the shipping address is one of the customer's address book or a new one; billing goes there too unless another billing address is given;
- the subscription keeps copies, so editing the address book afterwards changes nothing, and a new address is not added to the book;
- they apply from the next renewal whose order is not placed yet: an order already placed, for instance one awaiting a retry, keeps its addresses.
When the subscription's shipping method does not reach the new shipping address, the form lists the methods that do, the same ones the checkout would offer for its items there, each with what it would cost the next renewal at today's prices, and the subscription moves to the one chosen. When none reaches it, the change is refused and nothing changes. A subscription that ships nothing only changes its addresses.
Changing where a subscription ships is the first thing a stolen account does: listen to
SubscriptionAddressChanged and tell the customer. The change is a service,
SubscriptionAddressChangerInterface: replace it to change addresses another way.
Changing the items
A customer changes what an active subscription brings from their account, while its open cycle has no order yet: once the renewal's order is placed, for instance while its charge awaits a retry, that order keeps its items and nothing can be changed until the next cycle. The changes apply from the open cycle. On the "Change items" page, each item still renewing can:
- change its quantity, from 1 to the most Sylius allows on a cart line
(
sylius.order_item_quantity_modifier.limit, 9999 by default), keeping its frozen price; - move to another variant of the same product, which renews at that variant's current price less the discount of its plan, or of the store's frequency, of the subscription's interval, and keeps the cycles the item has been paid; an introductory price the item was still on ends;
- be removed, while another item still renews and it is past its minimum commitment.
A variant is offered when the channel sells it (enabled, its product in the channel, and at least one in stock when its stock is tracked), when it has terms of the subscription's interval of the item's kind (an enabled plan of its own for an item on a plan, the store's frequency, if the variant can be repeated, for an item repeated with it), when those terms' maximum of cycles is above what the item has been paid, and when no other renewing item has it.
The "Add a product" page lists, by product, every variant the channel sells with an enabled plan of the subscription's interval, or else that can be repeated with the store's frequency of that interval, and that no renewing item has: an item already renewing changes its quantity instead. The plan wins when a variant has both. The new item is frozen at the variant's current price less that discount, with no cycle paid.
When the changes raise what each renewal costs, the page shows the new total and asks the customer to accept the recurring charges again, and saves nothing until they do; see Consent to recurring charges. The "Add a product" page asks in the same step. Lowering a quantity, removing an item, or adding one that is free asks for nothing.
A removed item is not deleted: it stays on the subscription marked as removed, like one that reached its maximum of cycles, so the renewals that carried it keep showing it. It no longer renews, the frequency change leaves it alone, and the committed cycles do not count it. Its variant can be added again, as a new item. Nothing reserves stock: a renewal still skips what it cannot sell that day.
Listen to SubscriptionItemsChanged, which carries the totals before and after, to keep your own
record of each change. The changes are a service, SubscriptionItemEditorInterface: replace it to
offer other changes.
Retrying failed charges
What becomes of a charge that was declined or not attempted is decided by
JpmMartin\SyliusSubscriptionPlugin\Cycle\RetryPolicyInterface: charge the same order again at a
date, or fail the cycle. It is never asked about an administrator's retry, which is charged once.
The default policy retries after each of retry_delays, counted in days from the cycle's first
attempt, and fails the cycle once they run out. A decline whose code is in final_decline_codes fails
the cycle at once: retrying a stolen card only earns more declines. Codes are compared exactly and are
the gateway's own, so the list is empty by default. With Stripe, for instance, whose
decline codes include these:
For anything else, such as retrying by payment method, within hours, or by the gateway's advice,
implement RetryPolicyInterface and point the interface's alias at your service. nextAttemptAt() is
called once the failed attempt is among the cycle's attempts, with the charge's outcome and its code;
return the date to charge again, or null to fail the cycle.
Unpaid orders expiring
Sylius's sylius:cancel-unpaid-orders cancels the orders left unpaid for longer than
sylius_order.expiration.order, five days by default, which is shorter than the plugin's default
retries. So that it never cuts a retry short, the plugin decorates sylius.updater.unpaid_orders_state
with a version that leaves out the renewal orders whose cycle still awaits a retry: the retry policy
decides when to give up on them, and failing the cycle cancels them. The order of a customer's recovery,
which the plugin never charges, expires like any other. Every other order, initial orders
included, expires as before. If your store decorates or replaces that service too, keep renewal orders
awaiting payment out of it.
Paying a declined renewal
When a renewal's charge is declined, or could not be attempted, and a retry is to come, its customer
can pay the renewal order on Sylius's own order payment page, sylius_shop_order_show
(/order/{tokenValue}), with another card or another method:
- their account offers "Pay now" on that renewal, and
JpmMartin\SyliusSubscriptionPlugin\Payment\RenewalPaymentLinkGeneratorInterfacegives the page's absolute address for the store's own notice ofRenewalChargeDeclined; see Events; - on a renewal order, the page offers only the methods the renewal charger
supports(): the plugin decoratessylius.resolver.payment_methods, and leaves every other order as it was; - once paid, the cycle is paid as if the plugin had charged it, its retry is not made, and its history
shows "Paid by the customer". Paid with another method the plugin can charge, the subscription renews
with it from then on, and
SubscriptionPaymentMethodChangedis published. A payment an administrator marks complete in the admin pays the cycle too, but is not shown as the customer's.
A retry does not charge while the customer is paying the same order, so the renewal is not charged
twice: a payment request of the pending payment still new or processing holds the retry back to the
next run of the command. A customer who gives up on the gateway's page leaves that request behind, so it
counts only until it has gone customer_payment_wait_minutes without changing, an hour by default; the
retry then goes ahead. Only gateways that use Sylius's payment requests leave a request to find.
Changing the card
Without a renewal to pay, where the customer changes their card is the gateway integration's, since the
card is the gateway's. An integration implements
JpmMartin\SyliusSubscriptionPlugin\Payment\CardUpdateProviderInterface: supports() a subscription,
usually by its payment method, and getUrl() of its own page. Tag it with
jpm_martin_sylius_subscription.card_update_provider, or let autoconfiguration do it; the first that
supports a subscription, by the tag's priority, gives the account's "Change card" link, and
JpmMartin\SyliusSubscriptionPlugin\Payment\CardUpdateProviderRegistry gives it to your own templates
or emails. The plugin registers none, so nothing is offered until one is.
Recovering a suspended subscription
A subscription suspended after suspend_after_failed_cycles failed cycles in a row, the last of them on
a charge that was declined or could not be attempted, is suspended for unpaid renewals, and its customer
can recover it from their account with "Pay and reactivate". When a gate, an expired hold or nothing to
renew failed that last cycle, paying would not mend it: the subscription is suspended all the same, but
only an administrator can reactivate it.
- its last failed cycle is retried with a new order of the items that can be sold now, which the plugin does not charge: the customer pays it on Sylius's order payment page, with another card or another method the plugin can charge, as in Paying a declined renewal;
- once paid, the cycle is paid and the subscription reactivated: its next cycle is on the first date of its calendar after that day, and its run of failures starts afresh;
- starting again before paying leads to the same order. Left unpaid, it expires like any unpaid order,
after Sylius's
sylius_order.expiration.order, and the cycle fails again, without counting twice; the subscription stays suspended, and can be recovered again; - when none of its items can be sold, nothing is offered, and the account says why.
A suspension by an administrator is theirs to lift: its customer cannot recover it, and a recovery
started before an administrator suspended the subscription again pays its cycle and leaves it
suspended. RenewalPaymentLinkGeneratorInterface::generateRecovery($subscription) gives the absolute
address of the store's notice of SubscriptionSuspended when forUnpaidRenewals is true: once signed
in, the customer starts the recovery there and goes on to pay. It is null when the subscription
cannot be recovered. The recovery is a service, SubscriptionRecoveryInterface: replace it to let
customers recover another way.
Price updates
A subscription item keeps the price it was frozen at: a change of the catalogue does not reach it, so a temporary sale never touches the subscriptions. To pass a price change on, an administrator uses "Update subscription prices" on a plan's page, a store frequency's page, or the "Subscription" tab of a variant. Each item on it, of a subscription neither cancelled nor completed, is repriced as the cart prices a subscription line: its variant's current price in the subscription's channel, less the discount of its plan or store frequency. A preview first counts the subscriptions whose price would go up, go down or stay the same; confirming it needs the form's token.
- A decrease applies at once, without notice: the next renewal whose order is not placed yet is charged the new price.
- An increase is announced: it applies from the first renewal scheduled
price_increase_notice_daysdays or more after the update, 30 by default. The renewals before it keep the current price; the customer's account and the subscription's admin page show the new price and its date. Another increase of the same item replaces the pending one and starts the notice again; updating again to the price already pending changes nothing, and today's price withdraws it. - A renewal order already placed, for instance one awaiting a retry, keeps its price.
- A new variant or frequency, chosen by the customer, freezes today's price and drops the increase pending on that item.
- An introductory price is left as it was sold: an update reprices the normal price, which the item renews at once its introductory cycles are paid.
With price_increase_acceptance: required, the customer accepts the new price from their account. If
the first renewal at the new price comes before they do, the subscription is paused instead of renewed,
and it can only be resumed by accepting it: the account offers "Accept the new price and resume". Each
new increase asks again. Whether the default is notice or required in your country, and how many
days of notice you owe, is for you to find out: the plugin gives you the choice, not the law. Tell your
customers from SubscriptionPriceIncreaseAnnounced before asking for their acceptance; the plugin
sends nothing. To ask for it in some channels or countries only, implement
JpmMartin\SyliusSubscriptionPlugin\Pricing\PriceIncreaseAcceptancePolicyInterface and point the
interface's alias at your service.
Confirming an update sends one JpmMartin\SyliusSubscriptionPlugin\Command\UpdateSubscriptionPrices
per subscription on sylius.command_bus, each in its own transaction. They are handled at once by
default; when an update may reach thousands of subscriptions, route the message to an asynchronous
transport:
Introductory prices
A plan, on the variant's Subscription tab, or a store frequency can have an introductory price: an introductory discount, taken off the variant's price instead of the subscriber discount, for the first cycles of each new subscription. The form asks for it as "None", "First order only" or "The first N cycles", with the discount and, for the last, the number of cycles. The initial order is the first cycle: "First order only" discounts the order the customer places, and "The first 3 cycles" that order and the next two renewals.
- The cart prices the line at the introductory discount, before promotions and taxes, like any subscription line. The product page, under each plan, and the cart line tell the customer the introductory price, how many orders it lasts and the price after: "$10.00 for your first 3 orders, then $18.00". A line repeated with a store frequency says it too, and "Repeat this cart" gives each frequency's introductory discount: "Every month (50% off your first 3 orders, then save 5%)".
- The subscription freezes the normal price, the variant's price less the subscriber discount, and
each item keeps the introductory price and the cycles it was sold with (
introductoryUnitPrice,introductoryCycles): changing the plan or the frequency later changes neither. An item is charged its introductory price until it has paid that many cycles, the initial order included, and its frozen price after (SubscriptionItemInterface::getUnitPriceForCycle()); a skipped or failed cycle does not count. The customer's account shows the frozen price with the introductory price and how many renewals it still lasts; the price per renewal of the account and of the admin, and the totals of every event butIntroductoryPriceEndingandTrialEnding, are at the frozen prices. - It ends early when the item moves to another plan, frequency or variant: its next renewal is charged the normal price of its new terms, whose own introductory price is for new subscriptions. Changing the quantity keeps it, and so does a price update, which reprices the normal price only.
- A product added to an existing subscription from the customer's account gets no introductory price.
IntroductoryPriceEndingis published withRenewalUpcomingwhen the renewal it announces is the first in which an item is charged its normal price, with what that renewal will charge for its items, an increase that applies by then included. It is published only when renewals are announced: withrenewal_notice_days: null, keep a notice of your own if you tell customers the introductory price ends. See Events.
Prepaid deliveries
A plan, on the variant's Subscription tab, or a store frequency can charge its deliveries by blocks: "Deliveries per charge" above 1, which an introductory offer cannot go with. A monthly plan at $18.00 a delivery charging 3 deliveries at a time charges $54.00 every three months and delivers every month.
- The charge of a block is the first cycle of each block. Its order carries each item at its frozen price times the deliveries of the block, and is charged as any renewal, with its retries. The initial order charges the first block. The product page, under each plan, and the cart line say it: "Each charge pays for 3 deliveries: $54.00". Each item freezes the price of one delivery, which is what a price update reprices.
- The deliveries are the other cycles of the block. Each places its order on its date, with its lines
at 0 and no shipping charge, and closes without a charge: Sylius completes an order of nothing as paid,
with its shipment ready to be prepared and sent like any other. It goes through the gates like any
renewal, counts as a paid cycle for the maximum of cycles and the minimum commitment, is not announced
with
RenewalUpcomingand publishesPrepaidDeliveryPlacedinstead ofRenewalPaid. - Shipping is charged once per block, with its charge: the deliveries ship for free. A store that wants to charge the shipping of every delivery puts it in the plan's price. The same goes for taxes, worked out on the block's order: check with your accountant how you invoice prepaid deliveries.
- The maximum of cycles of such terms must be a whole number of blocks, so no charge pays for deliveries that will not be made.
- A block that cannot be charged is not delivered: its charge fails as any renewal, and the next cycle is the charge of the following block, on its date. A block paid late, by an administrator's retry or by its customer recovering the subscription, is delivered from the cycle scheduled meanwhile.
- Pausing keeps the deliveries paid for: resuming delivers them first, then comes the next charge.
- Cancelling: a customer who cancels with deliveries paid for still receives them on their dates and is charged nothing more; the subscription is cancelled after the last one, and the account says when. An administrator cancels at once: refunding what was not delivered is up to the store.
- Changing the frequency or the items is not offered while deliveries are paid for: the next charge can change them. An item added or moved to other terms keeps the subscription's deliveries per charge.
PrepaidDeliveryPlaced carries cycleId, cycleNumber, orderId and deliveriesLeft, the deliveries
still paid for after this one; see Events.
Minimum commitment
A plan, on the variant's Subscription tab, or a store frequency can have a minimum commitment: the cycles a subscriber pays, the initial order included, before they may cancel. "Six months at 20% off" is a monthly plan with a 20% discount and a commitment of 6.
- Each item keeps its own. An item is subscribed with its terms' commitment
(
commitmentCycles), which it keeps when an administrator edits the plan or the frequency later, and when its customer changes its frequency or its variant. A product added from the account to an existing subscription commits to nothing. - Only paid cycles count. A subscription is within its commitment while one of its items has been paid fewer cycles than it committed to. A skipped, failed or cancelled renewal does not count, so it makes the commitment last longer. A free trial's initial order counts, like any initial order.
- What the customer cannot do meanwhile: cancel the subscription, pause it, or remove a committed
item. The account neither offers them nor accepts a request that forces them, and shows how many
paid renewals are left. The customer can still skip renewals, within
max_consecutive_skips, and change the frequency, the variant, the quantities and the address. - What still can: an administrator cancels, pauses or suspends it, and the plugin still suspends it
after failed cycles in a row. The rule applies to the requests of the account's routes, which declare
_subscription_actor: customerin their defaults; a route of your own that lets customers cancel or pause must declare it too, or it will act as an administrator's. - What the customer sees before subscribing: the product page, under each plan, and the cart line say "Minimum commitment: you can cancel once you have paid 6 orders".
- The service is
JpmMartin\SyliusSubscriptionPlugin\Management\SubscriptionCommitmentInterface: point its alias at your own to count a commitment another way, in months for instance.
Whether the law of your customers allows a minimum commitment on a consumer subscription, and for how
long, is yours to find out: the plugin gives you the mechanism, off unless you set it. Say it in your
consent text too (jpm_martin_sylius_subscription.consent.text), since that is what your customers
accept; see Consent to recurring charges. There is no fee for leaving
early, and nothing charges what is left of a commitment: an administrator who cancels ends it.
Free trials
A plan or a store frequency can start each new subscription with a free trial of some days: in its admin form, "Introductory offer" is then "A free trial of N days", which an introductory price cannot go with. The initial order charges nothing for it, the subscription is activated once the gateway authorizes that order's payment of 0, right after it is placed, and its first charge is the renewal on the day the trial ends; the calendar follows the interval from there. Subscribing on 1 March to a monthly plan with 14 days free, the customer is first charged on 15 March, then on 15 April.
- The gateway keeps the card. A cart whose only charge is a free trial costs nothing, yet it keeps
a payment of 0, goes through Sylius's payment step, and has that payment authorized through a payment
request with the
authorizeaction, whatever the method does with other orders. Your gateway's integration must answer it by keeping the card without charging it, as a Stripe SetupIntent or a card verification does, so the renewals can be charged later without the customer, and then mark the paymentauthorized. The plugin knows no gateway: list intrial_payment_methodsthe methods whose integration does, and keep them inpayment_methodstoo. The checkout refuses a cart with a free trial paid with any other; with none listed, no cart is given a free trial. - Activation. The authorized payment of 0 activates the subscription. An order that has something else to pay, its shipping or other lines, is paid as any order, and paying it activates it. The initial order stays with its payment of 0 authorized: nothing is ever captured from it.
- Its own subscription. The lines given a free trial start a subscription of their own, even next to lines of the same interval that are paid at once, so each keeps one calendar. Each item freezes its normal price, the variant's price less the subscriber discount.
- Once per customer and variant. A customer who has, or had, a subscription with the variant, in
any state, pays from the first order. The rule is
JpmMartin\SyliusSubscriptionPlugin\Trial\TrialEligibilityInterface: point its alias at your own service to tell customers apart another way, by their card or their address for instance. A visitor sees the trial, and the cart checks it again once the customer signs in. - What the customer sees. The product page, under each plan, the cart line and "Repeat this cart" say how many days are free and the price after: "14 days free, then $18.00".
TrialEndingis published withRenewalUpcomingwhen the renewal it announces is the first charge after the trial, with what it will charge. If the customer skips that renewal, the next one is announced as the first charge. Withrenewal_notice_days: nullneither is published. See Events.
Missed dates
A cycle's date comes from its subscription's calendar, so a late charge never moves the cycles after
it. When a cycle is settled, paid, failed or cancelled, the next goes on the calendar's next date. That
date may already have come: after jpm-martin:subscription:process-cycles stopped running for a
while, or when a cycle of a short interval spent longer than its interval being retried. What becomes
of such dates is decided by JpmMartin\SyliusSubscriptionPlugin\Schedule\MissedCyclePolicyInterface,
and the default policy does what missed_cycles says:
skip, the default: the late cycle is charged once, and the next goes on the first date to come. The calendar keeps its day and time, and skipped dates create no cycle and use up no plan or frequency. After the command stopped from 1 March to 10 June, a monthly subscription due on 1 March is charged once on 10 June and renews next on 1 July. A weekly cycle that runs out of retries on day 7, after its time, skips the date that came meanwhile.charge: each date that came is charged, one per run of the command, each with its own order.skip_late: asskip, and a scheduled cycle whose next date has come too is cancelled instead of charged, without an order and without counting as a failed cycle. A cycle held by a gate is never skipped: its wait is deliberate.
The command says how many of the due cycles are more than one interval late, and each skip is logged as a warning with the subscription and its next date.
To decide it another way, implement MissedCyclePolicyInterface and point the interface's alias at your
service. datesToSkip() is asked, before a cycle is scheduled, how many of the dates that have come to
skip, from 0 up to all of them; isStillDue() is asked whether a scheduled cycle that is due is still
processed.
Gates
Before a cycle places its order, every gate is asked whether it passes, waits until a date (with a reason) or is rejected (with a reason). A waiting cycle is held and asked again on every run until the earliest deadline it was given; if that deadline arrives, or a gate rejects it, the cycle fails. Without gates, every cycle passes.
Implement JpmMartin\SyliusSubscriptionPlugin\Gate\CycleGateInterface; with autoconfiguration the
service is tagged jpm_martin_sylius_subscription.cycle_gate for you.
Consent to recurring charges
The text the customer accepts is the translation jpm_martin_sylius_subscription.consent.text
(domain messages). Override it in your translations and raise consent_version when you change
it: customers are then asked again, and each subscription keeps the version, text and date that were
accepted for it.
The same text is asked again when a customer's changes to a subscription's items raise what each renewal costs, so write it to fit both moments. The subscription then keeps the version, text and date of that acceptance instead; the consent recorded on the initial order stays as the proof of the first.
Store frequencies and repeated carts
- Frequencies are managed in the admin under Configuration > Subscription frequencies. Each has a code, a name, an interval, a discount, an optional introductory price (see Introductory prices), an optional maximum of cycles and the channels that offer it. Disable one to stop offering it; the subscriptions already on it keep renewing. One that a cart, an order line or a subscription item uses cannot be deleted.
- Variants that can be repeated are marked with the "Can be repeated with the store's frequencies" switch on the variant's Subscription tab, off by default, so a gift card or anything that must not renew stays out of repeated carts. The mark is kept in a table of the plugin; the store's variant class is left alone.
- The cart page offers "Repeat this cart" when the channel has a frequency and the cart a one-time
line of a variant that can be repeated. It is a form of its own, posting to the route
jpm_martin_sylius_subscription_shop_cart_repeat, rendered after Sylius's live cart form by the hookablejpm_martin_sylius_subscription_repeat_cartof the hooksylius_shop.cart.index.content(priority 50): override or move it there. Each line says what it renews on, or that it is bought once in a repeated cart. - The choice belongs to the cart. An order processor (priority 47, before the subscriber price at 45) gives the cart's frequency to each one-time line of a variant that can be repeated, and takes it from every other line, whenever Sylius processes the cart. A frequency the cart can no longer have, disabled or taken off its channel, stops the cart being repeated.
- Services:
JpmMartin\SyliusSubscriptionPlugin\Frequency\CartRepeaterInterfacechooses or removes a cart's frequency and tells which are offered;JpmMartin\SyliusSubscriptionPlugin\Frequency\RepeatableVariantsInterfacemarks variants and tells which can be repeated.
Shop API
POST /api/v2/shop/orders/{tokenValue}/subscription-itemsadds a line on a plan, next to Sylius's own/items, with the variant's and the plan's codes:{"productVariant": "COFFEE", "subscriptionPlan": "COFFEE_MONTHLY", "quantity": 1}.PATCH /api/v2/shop/orders/{tokenValue}/subscription-consentrecords the customer's consent before the order is completed.GET /api/v2/shop/subscription-frequencieslists the frequencies the request's channel offers, with their code, name,intervalCount,intervalUnit,discountPercentage, and their introductory price:introductoryDiscountPercentage, null without one, andintroductoryCycles, and their free trial intrialDays, null without one.PATCH /api/v2/shop/orders/{tokenValue}/subscription-frequencyrepeats the cart with one of them,{"subscriptionFrequency": "MONTHLY"}, or stops repeating it with{"subscriptionFrequency": null}(Content-Type: application/merge-patch+json). A frequency the channel does not offer is refused with a validation error.- Every line of a shop cart, in the cart and on its own (
/shop/orders/{tokenValue}/items/{id}), tells the code of its plan and of its frequency, or null:"subscriptionPlan": "COFFEE_MONTHLY", "subscriptionFrequency": null.
The customer's subscriptions
The signed-in customer (the shop API's token, POST /api/v2/shop/customers/token) sees and manages their
own subscriptions as their account does, with the same rules and the same services. A request without
a token answers 401, and another customer's subscription 404.
GET /api/v2/shop/subscriptionslists them:id,state,intervalCountandintervalUnitof its deliveries,deliveriesPerCharge,renewalTotalandchargeTotalin minor units ofcurrencyCode,nextRenewalAt, anditems(id,product,productVariant,quantity,unitPrice, a pending or introductory price, its plan or frequency,removed,renewable).GET /api/v2/shop/subscriptions/{id}addscycles(number,scheduledAt,state,charging, theorderNumberof its order,paidByCustomer, and theskippedItemsits order left out with theirreason), the addresses and methods it renews with, thefrequenciesit can change to, what is pending (a price increase to accept, the cycles left of a minimum commitment or of an item's introductory price, the deliveries paid for ahead and the date of the last of them), why a suspended subscription cannot be recovered (notRecoverableReason,nothing_to_renewwhen nothing of it is sold any more), the currentconsent(versionandtext), andactions: the names of what the account would offer now, amongcancel,pause,resume,skip_renewal,change_frequency,change_address,change_items,add_item,accept_price_increase,pay_renewal,recoverandupdate_card.-
Each action is a
PATCH(Content-Type: application/merge-patch+json) that answers the subscription as itsGETdoes:Path under /api/v2/shop/subscriptions/{id}Body /cancel,/pause,/resume,/skip-renewal{}/frequency{"intervalCount": 3, "intervalUnit": "month"}, one of itsfrequencies/address{"shippingAddress": {"firstName": ..., "lastName": ..., "street": ..., "city": ..., "postcode": ..., "countryCode": "US"}, "billingAddress": {...}, "shippingMethod": "DHL"}; the billing address and the method are optional/items{"items": [{"id": 12, "quantity": 2}, {"id": 13, "productVariant": "COFFEE_L"}, {"id": 14, "removed": true}], "acceptedConsentVersion": "1"}/add-item{"productVariant": "TEA", "quantity": 1, "acceptedConsentVersion": "1"}/accept-price-increase{"resume": true}to resume a subscription paused for itAn action the account would not offer now answers 422 with a violation saying why ("The next renewal of this subscription cannot be skipped now."), and changes nothing: within a minimum commitment, for one, it can be neither cancelled nor paused. With deliveries paid for ahead,
/cancelkeeps it active until the last of them, and then cancels it. A quantity is a whole number from 1 to the highest a cart line allows, and an address field is text; a number is taken as its text, so a postcode may be sent as one. A change of items that raises what each renewal costs needsacceptedConsentVersion, the version of theconsentthe detail gives to show: without it, it answers 422 and saves nothing. Each action is a command ofsylius.command_bus, so its events are delivered once its change is stored. GET /api/v2/shop/subscriptions/{id}/renewal-payment-link,/recovery-linkand/card-update-linkanswer{"url": "..."}: the absolute address of the order payment page of a renewal whose charge was declined, where the customer recovers a subscription suspended for unpaid renewals, or where their gateway changes their card. With no such link, as when the account shows none, they answer 404.
Committed cycles
JpmMartin\SyliusSubscriptionPlugin\Query\CommittedCyclesQueryInterface::forProductVariant($variant, new \DateInterval('P3M'))
returns, by date, the cycles the items of a variant in active subscriptions will renew within the
horizon, each with its subscription, item, date and quantity, never past the cycles the item's plan
or frequency still allows. It follows the missed cycle policy: a date the policy will skip is not
returned, nor a cycle it will cancel.
Events
The plugin sends no email, SMS or any other notice to customers: what to say, in which words and through
which channel is the store's. It publishes instead an event of its own at every moment of a
subscription's life, as a Symfony Messenger message on sylius.event_bus, the bus Sylius publishes its
own events on. Each event is a class of JpmMartin\SyliusSubscriptionPlugin\Event with public,
read-only properties: identifiers and simple data, never entities, so it can go through an asynchronous
transport. Every one implements SubscriptionEventInterface, whose getSubscriptionId() gives the
subscription; listen to that interface to receive them all.
| Event | Published when | Data besides subscriptionId |
|---|---|---|
SubscriptionActivated |
the subscription is activated: its initial order was paid | — |
SubscriptionPaused |
it is paused, by its customer or an administrator on the customer's behalf | — |
SubscriptionResumed |
the paused subscription is resumed | — |
SubscriptionSuspended |
it is suspended, by an administrator or after failed cycles in a row | forUnpaidRenewals: true when the last of those cycles failed on a charge, so its customer can recover it by paying |
SubscriptionReactivated |
it is reactivated | — |
SubscriptionCancelled |
it is cancelled, by its customer or an administrator | — |
SubscriptionCompleted |
it ends: no item has a cycle left to renew | — |
SubscriptionFrequencyChanged |
its frequency changes | intervalCount, intervalUnit (day, week, month or year) |
SubscriptionAddressChanged |
its addresses change, by its customer or an administrator | shippingMethodChanged |
SubscriptionItemsChanged |
its customer changes, removes or adds items | previousRenewalTotal, renewalTotal, in minor units of the subscription's currency |
SubscriptionPaymentMethodChanged |
its customer paid a declined renewal with another method the plugin can charge, which it renews with from then on | paymentMethodCode |
SubscriptionPriceIncreaseAnnounced |
an administrator's price update announced an increase | previousRenewalTotal, renewalTotal, appliesFrom, acceptanceRequired |
SubscriptionPriceChanged |
a price update applied a decrease, or an announced increase applied when its first renewal was processed | previousRenewalTotal, renewalTotal |
RenewalUpcoming |
a renewal is renewal_notice_days away, once per cycle |
cycleId, cycleNumber, scheduledAt |
TrialEnding |
with RenewalUpcoming, when that renewal is the first charge of a subscription that started with a free trial |
cycleId, cycleNumber, scheduledAt, renewalTotal, as for IntroductoryPriceEnding |
IntroductoryPriceEnding |
with RenewalUpcoming, when that renewal is the first in which an item is charged its normal price after its introductory one |
cycleId, cycleNumber, scheduledAt, renewalTotal: what that renewal charges for its items, before taxes, shipping and promotions, in minor units of the subscription's currency |
RenewalHeld |
a gate holds the cycle | cycleId, cycleNumber, holdUntil, reason |
RenewalOrderPlaced |
the cycle places its renewal order | cycleId, cycleNumber, orderId |
RenewalChargeDeclined |
a charge is declined or not attempted, and will be retried | cycleId, cycleNumber, orderId, nextAttemptAt, reason, code |
RenewalPaid |
the renewal order is paid | cycleId, cycleNumber, orderId |
PrepaidDeliveryPlaced |
a prepaid delivery places its order, without a charge; published instead of RenewalPaid |
cycleId, cycleNumber, orderId, deliveriesLeft |
RenewalFailed |
the cycle fails: retries run out, a gate rejects it, its hold expires or nothing could be renewed | cycleId, cycleNumber, orderId (null without an order), reason |
RenewalRetried |
an administrator retries a failed cycle, or its customer starts recovering a suspended subscription; RenewalPaid or RenewalFailed follows with its new order |
cycleId, cycleNumber |
RenewalSkipped |
the next renewal is skipped, by the customer or an administrator; published instead of RenewalCancelled |
cycleId, cycleNumber, scheduledAt, nextScheduledAt |
RenewalCancelled |
the cycle is cancelled: its order was cancelled before being paid, the subscription was paused, suspended or cancelled, or the cycle was skipped as late | cycleId, cycleNumber, orderId and reason, each null when there is none |
The renewal events come from renewals only: the first cycle is the initial order, paid when the
subscription is activated. The last retry that is declined publishes RenewalFailed, not
RenewalChargeDeclined, and an administrator's retry, which is charged once, never publishes
RenewalChargeDeclined. RenewalUpcoming is published by jpm-martin:subscription:process-cycles on
its first run within renewal_notice_days of a scheduled cycle of an active subscription, and not for
a cycle that is already due. So it reaches your customers only if the command runs at least once a day
or so. A subscription that renews more often than that, every day for instance, has its next cycle
within the notice as soon as it is scheduled: that cycle is announced in the same run that charged the
one before, so less than renewal_notice_days ahead.
A notice of RenewalChargeDeclined can tell the customer where to pay the renewal themselves:
JpmMartin\SyliusSubscriptionPlugin\Payment\RenewalPaymentLinkGeneratorInterface::generate($cycle)
gives the absolute address of the order payment page, or null once the renewal can no longer be paid
there. It is on the channel's hostname when the channel has one. Otherwise the host is the request's,
and a worker handling events has none: set framework.router.default_uri, or the link points to
localhost. Likewise, a notice of SubscriptionSuspended whose forUnpaidRenewals is true can give
generateRecovery($subscription), where the customer recovers it; see
Recovering a suspended subscription.
A handler, in a store with autoconfiguration:
When they are delivered:
- An event published while the command processes a cycle is delivered once that cycle's change is stored and committed, as Sylius's own events are, and not at all if it fails.
- An event of anything done outside the command, such as an administrator suspending a subscription, the
customer cancelling it or changing its frequency or its address, or the payment of its initial order
activating it, may be handled while that request runs, before its changes are stored. It waits for
them only when the action is itself a message of one of Sylius's command buses, which commit before
delivering: skipping a renewal is one, so
RenewalSkippedis delivered once the skip is stored, and not at all if the cycles command changed the cycle meanwhile. So is every action of the shop API on a customer's subscription. - A handler run synchronously that throws:
- in the command, makes it report the cycle as failed although the cycle was stored. Running the command again does not charge it twice, since the cycle changed, but the report misleads;
- anywhere else, stops the action, and its change is not stored: while your mail service is down, a customer could not cancel their subscription, nor an administrator suspend one.
So route the events you send notices from to an asynchronous transport, where a notice that fails is retried by the worker instead of stopping what happens to a subscription:
The plugin itself never stops a transition to publish its event: one of a subscription, cycle or renewal order that is not stored yet, which no flow of the plugin makes, publishes nothing and is logged. Only a handler of yours that throws, run synchronously, stops it, as above.
Known limitations
- Renewal orders are placed from code, and Sylius sends its order confirmation email only from the
shop's checkout and the API's, so no confirmation is sent for them. Send your own from
RenewalPaid; see Events. - An order that skips the payment step, one of 0 without a free trial, cannot start a subscription: the plugin needs the payment method it will charge the renewals with.
- With
missed_cycles: charge, the dates a subscription missed while the command did not run are processed one per run, each with its own order and its own charge. - Changing the frequency is not offered while the open cycle's order is awaiting payment, because that order keeps the old prices. It is only offered for intervals every item can move to: an item on a plan to an enabled plan of its variant, an item repeated with a store frequency to another enabled frequency of the channel, never from one kind to the other. Every item moves: one that had used up its plan or frequency renews again if the new one allows more cycles.
- "Repeat this cart" is outside Sylius's live cart form, so it shows what the cart had when the page was loaded: after lines are changed in place, it catches up on the next page load. The lines themselves always show what they renew on.
- The shop API lists frequencies without a page of their own: the
@idof each one in the list is not exposed, and answers 404. - A frequency's name is the admin's; the shop shows the interval and the discount instead ("Every month (save 5%)"), which are translated.
- An item that cannot be sold is skipped in every cycle until it can again, or until its customer changes or removes it.
- Products are added to a subscription from its page in the customer's account, not from the product page: the product page's form is Sylius's live cart form.
- With a gateway that charges through Payum rather than payment requests, a retry cannot see that the customer is paying the same renewal, so both could charge it. Prefer gateways with payment requests, Sylius 2's own.
- A change of items saved while the command places the open cycle's order may miss that order, which keeps the items it was placed with; the next renewal carries the change.
- The shop API does not take free trials yet: it would ask the payment of 0 to be captured, not authorized, so the gateway would not keep the card. Completing a cart with a free trial through it is refused with a message; the cart itself shows the trial as the shop does.
- MariaDB with Sylius 2.3 is not supported, and the cause is below the plugin. Sylius 2.3 runs DBAL 4,
which no longer takes MariaDB for MySQL: Sylius's own migrations, written for MySQL, skip a database
whose
serverVersionnames MariaDB, so its tables are never created; and one that does not name it is misread by DBAL 4 when it compares schemas. With Sylius 2.2, MariaDB works as described above. - An introductory price is offered to every new subscription, including one from a customer who cancelled another after its introductory cycles. To keep a first-order discount for first-time customers, use a Sylius promotion with the "Nth order" rule instead.
Development
The plugin is developed against Sylius's test application.
Configure the database in tests/TestApplication/.env.local and tests/TestApplication/.env.test.local.
Scenarios with JavaScript
The @javascript scenarios drive a headless Chrome listening on 127.0.0.1:9222 against the test
application served on the URL in BEHAT_BASE_URL (http://127.0.0.1:8080/ unless
tests/TestApplication/.env.test.local says otherwise). Any Chrome started with remote debugging
works; in Docker:
If the product page answers 500 with an empty body, PHP-FPM has run out of memory: the test
environment needs more than the 128M of a default php.ini. Raise memory_limit for the PHP the
server uses, for example with an extra ini file:
PHP_INI_SCAN_DIR=":/path/to/dir-with-a-memory-ini" symfony server:start ....
Another database
The tests use whatever DATABASE_URL says, and an environment variable wins over the .env files.
To run them on MariaDB 11.4, for example:
Run one suite at a time per checkout: the test clock is a file of the test application
(vendor/sylius/test-application/var/date.txt), so two runs from the same directory change each
other's date.
Continuous integration
Two workflows:
.github/workflows/build.yaml, on every push tomainand every pull request that changes more than Markdown files. With PostgreSQL 16, on PHP 8.2 with Sylius 2.2 and on PHP 8.4 with Sylius 2.3:composer validate --strict,composer audit, ECS, the container lint, PHPStan, PHPUnit and Behat without JavaScript. On MySQL 8.4, with Sylius 2.2 and 2.3, so with DBAL 3 and 4: the plugin's migrations built, taken down and up again, and compared with the mapping..github/workflows/install.yaml, when a release is published and on every change that can break an installation. A Sylius Standard store is created from scratch, the plugin is installed into it with the commands of Installation, read out of this README, and the file edits it shows, and the store is checked: the bundle, the routes, the plugin's tables and columns, and the shop's pages. The YAML files are taken whole from the README; the bundle line and the order item are fragments, placed bybin/apply-readme-edits, which has to change when their blocks do. A second job installs through the Flex recipe inrecipe/, compiled and linted assymfony/recipes-contribdoes:composer requirehas to end well while the store's order item is still Sylius Standard's, with the cycles command saying what is left, and the same checks follow once it is adapted; at the end,composer removehas to take back what the recipe did. Started by hand withrecipe_source: contrib, it takes the recipe fromsymfony/recipes-contribinstead, as a store does.
The @javascript scenarios, MariaDB, the other versions of MySQL and PostgreSQL, PHP 8.3 and 8.5, and
Symfony 6.4 are not checked on each change: run them locally, as above. Up to 1.0.0, each change was
checked on all of them, with Sylius 2.2. Sylius Standard, which the install workflow creates, is on
Sylius 2.2 until it moves to 2.3.
The tests charge renewals through a scripted gateway in the test application
(tests/TestApplication/src/Payment), with payment requests handled synchronously and encrypted with
a key kept for the tests only.
Versioning and changes
Released under Semantic Versioning: a caret constraint on this package is safe, and anything that would break an existing store arrives only in a major release with a written migration note. What changed in each release is in CHANGELOG.md; how a release is cut, and what counts as breaking, in RELEASING.md.
License
MIT. See LICENSE.