Skip to content

Developer notes

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, …).
  1. The cron takes a named MySQL lock (kvsub_...cron); a second process exits with skipped = locked.
  2. 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.
  3. The cycle row is inserted before anything else (INSERT IGNORE, then ROW_COUNT() = 1); the row is the idempotency key.
  4. 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.
  5. 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.
  6. 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.
// 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.

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.

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 (quantity, “Recalculate”, checkout steps, a restored saved cart): the cart form posts no line extras, so pre_add_to_cart copies 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_LOCK and then one per subscription. MySQL 5.7+ and MariaDB 10.0.2+ keep both; on MySQL 5.6 a second GET_LOCK releases 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) searches order_details.extra with LIKE; 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.

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).