Developer notes
Data model
Section titled “Data model”| Table | Purpose |
|---|---|
kv_sub_plans, kv_sub_plan_products |
Plans and the products they are offered on. |
kv_sub_subs |
A subscription: customer, company, plan snapshot (plan_json), status, mode (auto / link), payment and shipping ids, address snapshot, anchor (anchor_ts, anchor_cycle), next_cycle, next_at, counters. |
kv_sub_items |
Products of a subscription (product id, quantity, options). |
kv_sub_cycles |
One row per subscription and cycle with a UNIQUE key (sub_id, cycle_no): state, due time, order id, attempts, next retry. Cycle 0 is the first order. |
kv_sub_log |
Event history (activated, paused, cancelled by, order failed, …). |
How a renewal is made safe
Section titled “How a renewal is made safe”- The cron takes a named MySQL lock (
kvsub_...cron); a second process exits withskipped = locked. - Each subscription is processed under its own lock (
sub_<id>), so the cron, an admin click and the customer cannot change the same subscription at once. - The cycle row is inserted before anything else (
INSERT IGNORE, thenROW_COUNT() = 1); the row is the idempotency key. - The renewal order carries the key
S<sub>C<cycle>on its lines. If the process dies after the order exists but before the cycle row is updated, the next run finds the order by that key and adopts it instead of creating a second one. - The order is linked to its cycle and the subscription is advanced with
UPDATE ... WHERE next_cycle = <cycle>; only the run that changes one row proceeds. - A payment that is pending (order status Incomplete) keeps the cycle in
payment_pending; a final failed status (F, D, I) reopens the cycle and gives the delivery count back.
Hooks of the add-on
Section titled “Hooks of the add-on”// A payment add-on declares that it can charge a saved credential without the customer:function fn_my_gateway_kv_subscriptions_payment_driver(&$driver){ if (in_array($driver['payment_id'], my_gateway_payment_ids())) { $driver['supported'] = true; }}
// ... and charges the renewal order that was just created for that payment method:function fn_my_gateway_kv_subscriptions_charge($sub, $order_id, &$result){ // call your gateway with the stored token; then: $result['status'] = 'paid'; // or 'failed' / 'pending'}
// A subscription was cancelled (by = customer|admin|vendor|system):function fn_my_addon_kv_subscriptions_cancelled($sub_id, $by) {}Register them with fn_register_hooks('kv_subscriptions_payment_driver', 'kv_subscriptions_charge', 'kv_subscriptions_cancelled') in your add-on. paid sets the order to Processed, failed to Failed (the cycle is reopened and the dunning schedule starts), pending leaves the order as it is.
Classes (namespace Tygh\Addons\KvSubscriptions)
Section titled “Classes (namespace Tygh\Addons\KvSubscriptions)”Schedule: pure date arithmetic (add,due,firstDue,monthlyFactor); no CS-Cart calls, unit tested.Pricing: discount maths.Plans,Subscriptions,Billing,CartBuilder,Notifier,Stats,Links.
Renewal lines are added to a cart with CartBuilder::addLines(); the cart code (fn_add_product_to_cart, fn_calculate_cart_content, fn_place_order) does the pricing, taxes, promotions and shipping, so the order is a normal CS-Cart order.
Pricing hook
Section titled “Pricing hook”The plan discount is applied in get_cart_product_data_post_options after the option modifiers and before catalog promotions. Request values are never trusted: pre_add_to_cart checks that the plan exists, is active and is assigned to the product, and removes the renewal markers (kv_sub_id, kv_sub_cycle, kv_sub_key) from any request.
Storefront cache
Section titled “Storefront cache”The plan box depends on the product only. Account pages (kv_subscriptions.*) are never cached pages; e-mail links carry a signed token and no session data is printed on cacheable pages.
Cart updates, locks and limits
Section titled “Cart updates, locks and limits”- Cart updates (quantity, “Recalculate”, checkout steps, a restored saved cart): the cart form posts no line extras, so
pre_add_to_cartcopies the plan and renewal markers (kv_sub_plan,kv_sub_id,kv_sub_cycle,kv_sub_key) from the line already in the cart. Markers sent by a request are never trusted, and the quantity of a pay-link renewal line stays the quantity of the subscription. - A line that kept its plan marker while the licence lapsed is stripped in
pre_place_order, so it never creates a subscription later. - An Incomplete (status N) renewal order older than the setting “Abandoned payment timeout” (default 3 days) is set to Failed by the billing pass; the cycle then follows the dunning schedule. Cycles stuck as “Payment pending” for more than a day are listed under “Needs attention” on the dashboard.
- Cancelling the order of an older cycle (a refund months later) is only written to the history; only the cycle that is currently due is reopened.
- The billing pass takes a global
GET_LOCKand then one per subscription. MySQL 5.7+ and MariaDB 10.0.2+ keep both; on MySQL 5.6 a secondGET_LOCKreleases the first, and the unique (subscription, cycle) row plus the per-subscription claim still prevent a second order. Billing::findOrder()(the healing path after a crash) searchesorder_details.extrawithLIKE; it runs only for a cycle without an order and is not indexed, which is fine for stores up to some hundred thousand order lines.
Templates
Section titled “Templates”Storefront templates are installed into the responsive theme (and used by child themes such as Nova and Bright). The product page box is added in the hook products:add_to_cart (pre), the account link in profiles:my_account_menu, the cart tag in checkout:product_info.
tests/unit.php (date maths, pricing), tests/cli/e2e.php (real customer, cart, orders, cron double-run, dunning, pending payments), tests/e2e/e2e.mjs (HTTP: customer, guest link, admin, vendor).