2

I'm working in PHP and building a subscription management system from scratch.

I'm trying to figure out the required functions for the Subscription interface (OOP) that need to be implemented by classes that will keep track of subscription type, price, start/end dates of billing cycles, billing day of month, the start date of the subscription, the paid cycles count, etc.

Requirement & Purpose: the entire system will be queried each day to check if each subscription needs to be billed or re-billed if failed previously.

What is a good way to do this? Is what I have so far okay?

Conceptually, I thought of the following, but am not sure if I am going in the right direction:

/*** Run this daily ***/

// Retrieve from DB
$subscription = new Subscription_Magazine('SUBSCRIPTION_ID');

// Call a utils function
if (SubscriptionUtils::isPaymentNeeded($subscription))
{
    // Bill one cycle
    SubscriptionUtils::charge($subscription, $subscription->getPaymentMethod(), 1);
}


/*** Utils to handle logic ***/

class SubscriptionUtils
{
    const RETRY_ONLY_AFTER = "+1 day";

    public static function isPaymentNeeded($subscription)
    {
        // Check if neither active nor past due
        if (!$subscription->isActive() && !$subscription->isPastDue())
        {
            // Expired or canceled
            return false;
        }

        // Check for recurring
        if (!$subscription->isRecurring())
        {
            return false;
        }

        /* ? ? ?   |   |   |   |   |   |   |   ? ? ? */
        /* ? ? ?  \|/ \|/ \|/ \|/ \|/ \|/ \|/  ? ? ? */
        /* ? ? ?   V   V   V   V   V   V   V   ? ? ? */

        // Get subscribe date, cycles paid count, and length of cycle
        $regDate = $subscription->getSubscriptionStartDate(); //unix timestamp
        $cyclesPaid = $subscription->getPaidCyclesCount(); //int
        $cycleLength = $subscription->getCycleLength(); //"+1 month" / "+6 months" / "+1 year"

        // Check if paid through date is more than one month from now
        $paidThroughDate = self::modifyTimestamp($regDate, $cycleLength, $cyclesPaid);
        $oneCycleFromNow = self::modifyTimestamp(time(), $cycleLength);
        if ($paidThroughDate > $oneCycleFromNow)
        {
            return false;
        }

        // Check if we are on or passed the billing date
        $currentDay = date("j"); //int 1-31
        $billingDay = $subscription->getBillingDayOfMonth(); //int 1-31
        $daysInMonth = date("t"); //int 28-31
        // Use valid day
        $thisBillingDay = $billingDay > $daysInMonth ? $daysInMonth : $billingDay;
        if ($thisBillingDay < $billingDay)
        {
            return false;
        }

        // Check if we already tried recently
        $transaction = $subscriptions->getLastTransaction();
        if ($transaction instanceof Transaction && time() < modifyTimestamp($transaction->date(), self::RETRY_ONLY_AFTER))
        {
            return false;
        }

        //yes, return true that we need to charge now
        return true;

        /* ? ? ?   ^   ^   ^   ^   ^   ^   ^   ? ? ? */
        /* ? ? ?  /|\ /|\ /|\ /|\ /|\ /|\ /|\  ? ? ? */
        /* ? ? ?   |   |   |   |   |   |   |   ? ? ? */

    }

    public static function charge(Subscription $subscription, PaymentMethod $paymentMethod, $cyclesToCharge)
    {
        $transaction = new Transaction();
        $transaction->add("One Month Magazine", $subscription->getPrice(), $cyclesToCharge);
        $transaction->pay($paymentMethod);

        $subscription->addTransaction($transaction);

        if (!$transaction->success())
        {
            return false;
        }

        $subscription->paidCycleIncrement($cyclesToCharge);
        return true;
    }

    public static function modifyTimestamp($timestamp, $modifyText, $repeatNum = 1)
    {
        //Trivia code
        //Note: dates don't rollover.
        //For example: if "+1 month", then January 31st will result in February 28th (or 29th)
    }

    //...other stuff too
}
2
  • Is there any reason why you think that it won't work? – Bart van Ingen Schenau Aug 1 '16 at 9:47
  • I wasn't sure if there was a better way. I was questioning whether I should keep track of cycles or keep tracks of actual timestamps. Keeping track of cycles means having to recalculate timestamps each time. – Joseph Shih Aug 2 '16 at 0:08
2

You should rethink this. You should split the billing/payment logic from the subscription renewal logic. For the renewal logic I'd be inclined to store a 'next renewal date' that I calculate and update each time I renew the subscriber (and on first entry of a subscriber). In this way you only need to query for records that are due for renewal. Your logic above will visit every record in the database to check if it is eligible for renewal and that is not a very scalable approach.

In the renewal loop you would then create a new billing item for the customer. Further logic would process these billing items - each billing item will trigger a payment item and you track the success/failure of the payment items as the payment is processed. As the payment status updates you can use that to update the subscription too (for example a payment might fail so you may want to put a subscription on hold until payment is received).

Subscriptions and payments are quite complex with a fair few ifs and buts in them so any solution design will be quite complex.

3
  • Thanks for your thoughts. I think splitting the logic is best as you pointed out. I will do that. Regarding querying records, what I have above doesn't traverse or query the records. Everytime a successful payment is made, it just increments an (int) counter for each cycle paid. The script will simply calculate the paidThroughDate by taking the original subscription timestamp and modifying it by the number of cycles times length of cycle. What do you think about that? After my clarification, do you think this approach is scalable? – Joseph Shih Aug 2 '16 at 9:13
  • 1
    Maybe I'm not following your logic, but it looks like you are getting a subscription record and then checking if a payment is needed. As I said in my answer I would work out the next date a subscription needs to be processed on and store that each time I process the sub. For example suppose I have a weekly sub and it should process every Monday. When I process today's sub (assume it is Monday today) I would store the next sub date as next Monday. In my daily processing of subs I would then just need to loop over all subs where the next scheduled date was today. So I'm letting SQL do the work. – JohnXF Aug 2 '16 at 15:44
  • Yeah, you're right there. I missread what you meant. Thanks for clarifying. Yes, my approach will have to grab all active subscriptions instead of letting SQL take care of filtering. Maybe I can do both, using the cycles to verify, but also recording when the next payment is due. As you pointed it out, that would simply let the SQL query WHERE statement take care of filtering instead of having PHP figure it out. Thanks! – Joseph Shih Aug 3 '16 at 0:27

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.