Originally, I only wanted to sell user groups
Originally, I only wanted to sell user groups.
Buy a product → receive a user group. Done.
Not exactly spectacular.
Until one thought occurred to me:
Why only user groups?
If the shop can trigger something after a purchase, that something could just as well be a trophy. Or additional storage. Another listing in a directory. Or a feature in another plugin that remains unlocked for 30 days.
That was the moment when “selling user groups” turned into something rather bigger.
And that is how YoureCom Shop: Monetization came about.
Buy me a feature
The basic idea is wonderfully simple.
The shop is already quite good at collecting money. It knows products, orders, customers and payments.
What it could not answer before was a different question:
What does the customer actually receive inside the community for their money?
Not as a download. As an actual feature.
For example, you create a “Premium Membership” product and define that anyone buying it receives the “Premium” user group for 30 days.
The customer buys. The group is assigned. Thirty days later, it is removed automatically.
Or anyone buying a certain product permanently receives the “Supporter” trophy.
Purchase. Trophy appears. Done.
Together with YoureCom Shop: Subscriptions, it becomes even more interesting.
The entitlement follows the subscription. While the subscription is active, the feature is active. A renewal keeps it in place. When the subscription ends, the entitlement disappears as well.
At this point, I could say: Great. I built a user-group and trophy seller.
But that is exactly what I did not want.
Why should I decide what you can sell?
That was the genuinely interesting part of the development for me.
I cannot possibly know which features other developers may want to monetize in the future. And honestly, I do not want to publish a core update for every new use case either.
Today, someone wants to sell user groups. Tomorrow, someone wants to offer additional listings in a business directory. The next developer wants to disable advertising for €4.99 per month.
Someone else sells additional character slots in a game. Or more storage. Special profile features. A highlighted listing. Or 50 additional units of whatever their plugin happens to do.
Why should Monetization need to know anything about that?
It does not.
Just tell the shop what to do after purchase
That is why Monetization is extensible.
Another plugin can register its own entitlement type and essentially say: “Hello shop. I have something here that can be sold.”
Monetization then handles all the boring work around it:
- When should the feature be activated?
- How long does it remain valid?
- When should it be removed?
- Did activation succeed?
- Is it actually still active?
- Should another attempt be made after an error?
The actual plugin only needs to explain how its feature is activated and deactivated.
This is the extension point:
de.yourecom.shop.monetization.entitlementHandler
And this is where it becomes interesting for developers.
Let us build something sellable
As an example, imagine a plugin that can highlight a specific thread.
Whether this would make sense as a real product is irrelevant. It demonstrates the principle nicely.
We want to be able to say: “This customer may highlight this thread for seven days.”
So we teach our plugin that this feature can be sold.
1. Introduce Monetization to the package
<requiredpackages>
<requiredpackage minversion="6.2.0">de.yourecom.shop.monetization</requiredpackage>
</requiredpackages>
<instructions type="install">
<instruction type="file" application="shop" />
<instruction type="language" />
<instruction type="objectType" />
</instructions>
Nothing exciting yet.
2. Now we say: “I have something to sell”
<?xml version="1.0" encoding="UTF-8"?>
<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/objectType.xsd">
<import>
<type>
<name>de.example.forumboost.threadBoost</name>
<definitionname>de.yourecom.shop.monetization.entitlementHandler</definitionname>
<classname>example\forumboost\system\entitlement\ThreadBoostEntitlementHandler</classname>
</type>
</import>
</data>
Monetization now knows that a new entitlement type exists.
What does it do? No idea. And that is precisely the point.
Only our plugin needs to know.
Now for the interesting part
Our handler has three important jobs: grant, revoke and verify.
A heavily simplified version could look like this:
<?php
namespace example\forumboost\system\entitlement;
use shop\data\monetization\entitlement\Entitlement;
use shop\data\monetization\grant\Grant;
use shop\system\entitlement\AbstractEntitlementHandler;
use wbb\data\thread\Thread;
use wbb\data\thread\ThreadEditor;
use wcf\system\exception\UserInputException;
use wcf\system\form\builder\field\IntegerFormField;
use wcf\system\form\builder\IFormDocument;
final class ThreadBoostEntitlementHandler extends AbstractEntitlementHandler
{
public function validateConfig(array $config): void
{
if (!(new Thread((int)($config['threadID'] ?? 0)))->threadID) {
throw new UserInputException('threadID', 'invalid');
}
}
public function requiresDuration(): bool
{
return true;
}
public function describe(Entitlement $entitlement): string
{
$thread = new Thread((int)($entitlement->getConfig()['threadID'] ?? 0));
return $thread->threadID ? $thread->getTitle() : '';
}
public function grant(Grant $grant): void
{
(new ThreadEditor($this->getThread($grant)))->update(['isSticky' => 1]);
}
public function revoke(Grant $grant): void
{
(new ThreadEditor($this->getThread($grant)))->update(['isSticky' => 0]);
}
public function isActive(Grant $grant): bool
{
return (bool)$this->getThread($grant)->isSticky;
}
public function getConfigFormFields(): array
{
return [
IntegerFormField::create('configThreadBoostThreadID')
->label('forumboost.acp.entitlement.threadID')
->required(),
];
}
public function getConfigFromFormFields(IFormDocument $form): array
{
$field = $form->getNodeById('configThreadBoostThreadID');
\assert($field instanceof IntegerFormField);
return ['threadID' => (int)$field->getValue()];
}
public function populateConfigFormFields(IFormDocument $form, array $config): void
{
$field = $form->getNodeById('configThreadBoostThreadID');
\assert($field instanceof IntegerFormField);
$field->value((int)($config['threadID'] ?? 0));
}
private function getThread(Grant $grant): Thread
{
return new Thread((int)$grant->getEntitlement()->getConfig()['threadID']);
}
}
Display More
grant() says: turn it on.
revoke() says: turn it off again.
isActive() answers: is it really on?
Monetization handles the rest.
Of course, this example is intentionally simplified. A production implementation must account for overlapping active purchases, perform changes through the appropriate domain APIs and store a target selected by the buyer.
But the principle is what matters.
Monetization does not need to know what a thread is. It does not need to know what “sticky” means. And it certainly does not need to be modified for this plugin.
How does it appear in the ACP?
The handler can provide that part as well.
Through validateConfig(), describe(), getConfigFormFields(), getConfigFromFormFields() and populateConfigFormFields(), it can supply its own configuration.
Add one language item:
<?xml version="1.0" encoding="UTF-8"?>
<language xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.woltlab.com http://www.woltlab.com/XSD/5.4/language.xsd" languagecode="en">
<import>
<category name="monetization.acp.entitlementHandler">
<item name="monetization.acp.entitlementHandler.de.example.forumboost.threadBoost"><![CDATA[Highlight thread]]></item>
</category>
<category name="forumboost.acp.entitlement">
<item name="forumboost.acp.entitlement.threadID"><![CDATA[Thread-ID]]></item>
</category>
</import>
</language>
Display More
Install the plugin. The new entitlement type is available in Monetization.
Without a single change to the Monetization plugin.
That is the part I personally find rather cool.
And suddenly the shop no longer sells products
At least, not only products.
Once you take the idea a little further, it becomes quite interesting.
A developer builds a feature. They register a handler. An operator turns it into a product.
Suddenly, that feature can be sold once for €9.99, for 30 days, for a year or as part of a subscription.
The developer does not need to build a checkout. Integrate payment methods. Develop a separate subscription lifecycle. Or cobble together a cronjob that removes user groups during the night.
All of that already exists.
They only need to build the thing they actually want to earn money with: their feature.
And that is exactly why this is not called “Sell User Groups”.
It is called Monetization.
User groups and trophies are merely two examples.
YoureCom does not decide what will ultimately be sold through it.
You do.