Once an indie site is running, the first question you can't avoid isn't "should we do memberships?" It's "where does the money come from, and where does the cost sit?" This project chose a path worth studying: membership isn't bought directly — it's earned by inviting friends. Invite enough people, and you're a member. And the core benefit of membership is a larger AI token quota.
Why a token quota? Because, as the a21 article explained, every AI call spends real tokens — a normal user asking the AI ten questions can burn hundreds to thousands of metered tokens. AI cost is the most real, most unignorable expense of an indie site. Making it the anchor of "membership benefits" is both cost control and a revenue engine. This article lays open the whole economic chain: invite → membership → quota → reset/downgrade.
The core anchor of the membership system is the AI token quota. The quota isn't set on a whim — it's a tiered table:
public enum AiTokenQuotaEnum {
// text, value, limit5h, limitWeek, imageGenWeek, imageSearchWeek, imageAnalysisWeek
NORMAL("Normal User", 0, 80000L, 300000L, 5L, 100L, 10L),
PRO ("Pro Member", 1, 180000L, 700000L, 15L, 500L, 30L),
PLUS ("Plus Member/Admin", 2, 400000L, 1500000L, 30L, 2000L, 100L);
// ...
}
This table hides the essence of the whole quota system. Look at the columns: limit5h (5-hour quota) and limitWeek (weekly quota) — corresponding to the Redis rolling windows from the a21 article; limitImageGenWeek (weekly image generations), limitImageSearchWeek (weekly image searches), limitImageAnalysisWeek (weekly image analyses) — three "per-feature" quotas.
Why split the quota by feature instead of only a single "total token" pool? Because the cost difference between AI features is enormous: generating one image can cost as much as dozens of text chats, image search calls a vector model, and image analysis calls a multimodal model. If you set only one total token pool, users will spend it on the most expensive thing (generating images like crazy), blowing your cost up. Splitting the quota by feature is like giving each feature its own budget — cost control has to follow the per-unit cost, not one-size-fits-all. That's lesson one of AI quota design.
The "numbers" in the quota table aren't random either — behind them is a round of cost accounting: how many tokens one image generation costs on average, how much one vector-model image search costs, how much one multimodal image analysis call costs — each feature gets its own ledger, and you work backward to "how much per week is reasonable." The ceiling of a quota is, at bottom, "the ceiling of cost you can bear" — a normal user's quota is roughly "the AI cost you're willing to bear for one free user"; a member's quota is "a paying user deserves a bigger investment."
And that fallback of "unknown tier falls back to Normal" deserves another look: it isn't just defending against illegal input — it's a strategy of gradual rollout. When a new feature or a new tier isn't configured right, better to give too little than too much. Fallbacks in risk-control and cost scenarios always err on the side of strict. In a cost system, all unknowns are charged at the cheapest rate — that's the fuse that keeps cost from spiraling out of control.
Finally, this table pairs with a21's "model multiplier": the multiplier governs "how much one call costs," and the quota table governs "how much you can spend in total." One controls the unit price, the other the total volume — only together are they complete cost control. Unit price × total volume is the real AI cost — control only one and the other leaks.
How does the quota land on an actual request? Going back to the a21 article: get5hLimit reads this table by member type:
private long get5hLimit(int memberType, boolean isAdmin) {
AiTokenQuotaEnum quotaEnum = AiTokenQuotaEnum.getEnumByValue(isAdmin ? 2 : memberType);
if (quotaEnum == null) quotaEnum = AiTokenQuotaEnum.NORMAL; // unknown tier defaults to Normal
return quotaEnum.getLimit5h();
}
Note that fallback: quotaEnum == null → NORMAL — even if an illegal member type comes in, it falls back to a normal user, never letting a request through just because "no quota was found." Fallbacks for quota lookups must be strict — if nothing is found, use the lowest tier; better to give too little than too much.
The quota table maps to three membership tiers: Normal, Pro, and Plus. Each tier isn't just "a few more tokens" — it's a whole package of benefits:
Normal User — 80,000 tokens per 5 hours, 300,000 per week, 5 image generations per week
Pro Member — 180,000 per 5 hours, 700,000 per week, 15 image generations
Plus Member — 400,000 per 5 hours, 1,500,000 per week, 30 image generations
The numbers from 80,000 to 400,000, from 5 to 30, are all "multiples" apart — which gives users a clear "reason to upgrade": a normal user keeps hitting the quota line while using the product, sees Pro's allowance, and naturally wants to upgrade. The difference in membership benefits has to be visible at a glance, or users won't feel the need to upgrade. And these three tiers are "multiples" rather than "a bit more" precisely to amplify that perception.
One easily-missed detail: a normal user's quota isn't "0" — it's enough for light daily use (a few questions a day, one or two images), just not enough for heavy use. This design is crucial: a free user can't be "completely unable to use it", or they'll never feel the value of AI and have no reason to upgrade. The free tier should be "enough to taste the good stuff, but not enough to be satisfied" — users upgrade to get the satisfaction. Give too little and users leave; give too much and nobody upgrades. That line in between is the boundary between free and paid — and it's found by testing with data, not by guessing.
Members also have space benefits — SpaceLevelEnum defines the space capacity (storage limit, image-count limit) for each tier. A normal user's space quota is limited; members get more. The key design here: the space quota is "soft" — when users exceed it, data isn't deleted, they just can't upload more (more on that in the downgrade section). "Exceed and stop, but don't delete" is the most humane quota strategy for a content product — it protects users' work while holding the line on cost.
The frontend has a MemberMechanismModal that spells out the benefits: the three tiers' quotas, model multipliers (Qwen3.5-Plus 2.55×, DeepSeek-V4-Pro 16.36×), and upgrade conditions. Membership benefits have to be explainable clearly, or users won't upgrade — a membership whose benefits can't be stated clearly is no membership at all.

The three tiers also hide a design choice: membership isn't "bought," it's "earned." A normal user can't just spend 30 yuan to buy Pro — they have to invite. This "trade behavior for benefits" mechanism adds a layer of "investment" that direct purchase doesn't: users didn't "buy" their membership, they "earned" it, so they value it more and stick around. In psychology this is the "endowment effect" — the more effort you put into getting something, the more you value it. Replacing "buy" with "earn" is one of the smartest parts of this membership system — it turns customer-acquisition cost into users' own voluntary behavior.
The space benefit deserves expansion too: SpaceLevelEnum defines each tier's space capacity (storage MB + image-count limit); higher tiers get more space. Why tier the space as well? Because space is a real storage cost — every image on an image site occupies COS space. Giving members more space is both a benefit and a cost allocation. Every tier of membership benefit corresponds to a real cost behind it — tokens are compute cost, space is storage cost. Benefit design should have "a cost anchor for every benefit," or it's a promise without backing.
One more easily-missed point: membership is "upgradeable and downgradeable." Users can rise to Pro by inviting, auto-drop back to Normal on expiry, then invite again and rise again. This "flowing up and down" design makes membership not "a decision for life" but "continuous reward for behavior." Every time users see their tier, it reminds them "time to recruit new users." The liquidity of membership motivates more than a fixed identity — a fixed identity makes people coast; a flowing identity keeps people investing.
Membership isn't bought — it's earned through invites. The whole chain starts with "filling in an invite code at registration":
public long userRegister(String email, String userPassword, String checkPassword, String code, String inviteCode) {
Long inviterId = 0L;
if (StrUtil.isNotBlank(inviteCode)) {
// use the invite code to find the inviter
QueryWrapper<User> inviterQuery = new QueryWrapper<>();
inviterQuery.eq("inviteCode", inviteCode);
User inviter = userService.getOne(inviterQuery);
if (inviter != null) inviterId = inviter.getId();
}
user.setInviterId(inviterId); // record who invited me
if (inviterId > 0) {
// create an invite record
InviteRecord inviteRecord = new InviteRecord();
inviteRecord.setInviterId(inviterId);
inviteRecord.setInviteCode(inviteCode);
// ...
inviteRecordMapper.insert(inviteRecord);
// key: the inviter may be upgraded to a member
calculateAndUpgradeMember(inviterId);
}
return user.getId();
}
Three details. inviterId — the new user is bound to their inviter, and this "relationship chain" is established: the new user becomes the inviter's downline, and the inviter gets an "invite record." inviteRecord — every registration with an invite code creates a record, status pending confirmation; once confirmed (status=1) it counts as a valid invite. calculateAndUpgradeMember(inviterId) — this is the key line: when a new user registers successfully, the inviter may be upgraded from a normal user straight to Pro!
This "invite and upgrade" design is the growth engine of this membership system: users who want membership don't need to spend money — they just need to bring people. Each valid invite brings them one step closer. Compared to "buying a membership," this fits the cold start of an early community better — paying has a high barrier, but inviting is something everyone can do. Trading "invites" for "benefits" is the cheapest growth lever an indie site has — it motivates old users to recruit and new users to join, all without spending a cent.
The invite code itself is also worth talking about: when each user registers, the system generates a unique inviteCode shown on the invite page with one-click copy. This code is the user's "recruitment business card" — an old user sends the code to friends, friends fill it in at registration, and the relationship chain is formed. The invite code is a tool that turns every user into a distributor — recruitment no longer depends on platform operations but on each user's own initiative.
Another detail: when a new user fills in an invite code at registration, they're bound to an inviterId — this "relationship" isn't only useful at registration; it can also affect later recommendations (the recommendation system in the a14 article may use social relationships). One invite relationship can create value in multiple systems — at registration it upgrades the inviter; in recommendations it might be the "friend recommended" signal. When designing data, storing one more layer of relationship pays off in more places than you'd think.
The invite code also has a leak-prevention design in the invite code: if someone fills in someone else's code, the backend validates the code belongs to a real user (userService.getOne); if not found, it's treated as "not filled in" (inviterId=0) without an error — illegal invite codes are silently ignored, not an error that blocks registration. Because the invite code is optional, filling it wrong shouldn't stall a user who wants to register. A user who fails registration because of a wrong invite code is the most unjustified loss there is.
calculateAndUpgradeMember is the heart of the upgrade. It decides the inviter's tier by "valid invite count," with anti-abuse built in:
private void calculateAndUpgradeMember(Long userId) {
User user = this.getById(userId);
if (user == null || isAdmin(user)) return;
// 1. get all invite records with status 1 (valid)
List<InviteRecord> allRecords = inviteRecordMapper.selectList(
new QueryWrapper<InviteRecord>()
.eq("inviterId", userId).eq("status", 1).eq("isDelete", 0)
.orderByAsc("confirmTime"));
// 2. at most 5 per day, to prevent abuse
Map<String, Integer> dailyCountMap = new HashMap<>();
List<InviteRecord> validRecords = new ArrayList<>();
for (InviteRecord record : allRecords) {
String day = formatDay(record.getConfirmTime());
int count = dailyCountMap.getOrDefault(day, 0);
if (count < 5) { // at most 5 valid invites count per day
validRecords.add(record);
dailyCountMap.put(day, count + 1);
}
}
// 3. upgrade membership by valid invite count
// ... invite enough N people → Pro, enough M people → Plus
}
The most worth explaining here is the anti-abuse: at most 5 per day — why? Because "invite to upgrade" has a natural cheating point: a person registers a bunch of dummy accounts and uses the same invite code to invite each other, grinding out dozens of "valid invites" in one day to upgrade straight to membership. Limiting "valid invites to 5 per day" caps a single day's grinding — even if you cheat, you get 5 a day, and reaching the upgrade threshold takes many days, raising the cost. Any "behavior for benefits" mechanism has to defend against "batch grinding" — invite caps, daily limits, anomaly detection — none of them can be missing.
Besides the "5 per day" cap, the upgrade has a "time order" detail: orderByAsc("confirmTime") — invite records are sorted by confirmation time ascending, first-confirmed-first-counted. Why time order instead of arbitrary order? Because "valid invite" has a confirmation flow (a new user has to confirm after registering before status=1), and sorting by confirmation time guarantees "first confirmed counts first" — consistent and predictable. For logic that involves counting, the determinism of sorting matters — running the same dataset twice should give the same result, or ops will see baffling changes.
The upgrade threshold (how many invites to Pro / how many to Plus) is the "pricing" of this system — it decides "how many new users one member is worth." Set the threshold too high and users find it out of reach and give up recruiting; too low and membership is too easy to get, devaluing the benefits. The upgrade threshold is the lever that most needs ops tuning — it isn't fixed once and forever; it has to be adjusted against recruiting data. Which is why it should be configurable, not hard-coded.
Upgrade timing also has a detail to the upgrade: calculateAndUpgradeMember is called synchronously at registration — when a new user registers successfully, the inviter is upgraded on the spot, no waiting. Why "immediate"? Because an upgrade is a user's "instant feedback": the inviter gets "your invited new user registered and you're now Pro," and that instant feeling is the sustained driver of recruiting. If it were computed tomorrow, users would have forgotten who they invited, and the incentive of the upgrade goes cold. Incentives have to be paid out immediately; a delayed incentive is no incentive at all — same principle as "award the reward the moment a quest completes" in games.
The upgrade threshold (how many invites to Pro, how many to Plus) is configurable, and the frontend membership modal shows "Pro: invite X people, Plus: invite Y people." This threshold is also an ops lever: low early on (invite 3 to reach Pro) to recruit fast; raised once scale builds. Growth levers have to be adjustable — hard-code the threshold and ops has to ship a release to change it.
Invites are the "acquisition" engine; sign-in is the "retention" engine. /add/sign_in signs in once a day and rewards tokens:
@PostMapping("/add/sign_in")
@SaCheckLogin // must be logged in to sign in
public BaseResponse<Boolean> addSignIn(HttpServletRequest request) {
// record today's sign-in; return false if already signed in today
// on successful sign-in, reward a certain amount of tokens
}
The intent of sign-in is simple: get users to "come every day." AI features don't have great stickiness by themselves — users leave right after using them and don't come back. Sign-in gives a "reason to come once a day": come, sign in, claim tokens, and check out the new stuff while you're here. It trades a "small reward" for "daily active users" — the token cost is low (a normal user gets only a little per sign-in), but what you get in return is users opening the app every day. The "anchor" of the sign-in reward has to be chosen right: too expensive (like gifting a whole image-generation allowance) and cost spirals; too cheap (1 token) and users have no motivation. It has to sit in the middle of "feels worth it to users, but cost is controlled."
Sign-in also meshes with the membership system: the tokens gifted by sign-in are spent on AI features, and the AI quota is set by the membership tier. Sign-in drives traffic; membership monetizes — sign-in attracts users to use AI, heavy AI use hits the quota line, and users go upgrade. The closed loop of the entire economic system begins to take shape here.
Sign-in has an engineering detail too: the sign-in record table (UserSignInRecord) and the sign-in endpoint — /add/sign_in is called once a day, the backend checks "signed in today?" and returns false if yes, records a new row and rewards if not. This "signed in today or not" check is the core state of the sign-in feature — it can be a database query, or a Redis daily key (sign_in:{userId}:{yyyy-MM-dd}) storing a marker. The advantage of the Redis daily key is that it naturally expires (gone the next day) and is O(1) to look up — faster than hitting the database. "Have I done this today" is a classic Redis use case — one-shot, valid for the day, auto-expires the next.
The "amount" of the sign-in reward is also carefully set: too stingy and users feel sign-in is pointless and stop; too generous and cost runs away. The choice here is "a little token" — enough for users to feel "not a loss to show up," but nowhere near covering a day of AI usage. The sign-in reward is a hook, not the main course — its job is to get users to show up, not to make them self-sufficient.
The "streak" design of sign-in is also worth mentioning: consecutive sign-ins usually earn extra rewards (7 straight days gets more). Why encourage streaks? Because "streak" creates sunk cost — users who've signed in 6 days and skip the 7th lose the streak, so they show up to keep it alive. "Streak" is one of the strongest retention hooks — it turns "come every day" into "can't miss a day," stickier than single-day sign-ins. Of course, the reward gradient of streaks has to be designed well — the extra reward for streaks should be "worth protecting," but not "unmissable."
The quota can't "accumulate forever" — if unused weekly tokens roll over into next month, users' consumption piles up into a huge hole and cost goes out of control. So the quota is reset periodically:
@Scheduled(cron = "0 0 0 * * MON") // midnight every Monday
public void resetWeeklyAiTokens() {
// clear all users' weekly quota counters
stringRedisTemplate.delete(KEY_WEEK);
stringRedisTemplate.delete(KEY_IMAGE_GEN_WEEK);
}
At midnight every Monday, the "weekly tokens" and "weekly image generations" counters are cleared and users get a fresh round of quota. This design has two deeper meanings. Predictable cost — every user can spend at most that much per week, so cost has a ceiling; a rhythm to the experience — every Monday the "quota refreshes" and users feel "I can use it again this week." That refresh is itself a hook that brings users back.
Note it clears "Redis counters" rather than "changing the quota value in the database" — because the quota counter is already a Redis rolling window (covered in the a21 article), so resetting means clearing the window: simple, atomic, no database touch. For periodic resources, "clearing the counter" is far cleaner than "changing the value" — a counter is state; clearing it is the reset, with no historical baggage.
The "rhythm" of the reset is worth discussing too: why weekly rather than daily? Reset daily and consumption gets fragmented (today's leftovers become tomorrow's fresh allowance), piling cost pressure; reset weekly and users "plan their usage" within the week ("15 image generations this week — I should use them carefully"), making cost more predictable. The quota period determines the user's mindset — the longer the period, the more users "manage" their quota and the more controllable the platform's cost. A week is a balance point of "enough and controllable."
A hidden gain: the weekly reset is itself a "return hook." Users who run out of image-generation allowance on Wednesday and see the reset on Friday will think "I can generate images again this week" — a weekly "reason to come back." The periodicity of the quota isn't just cost control; it quietly gives users "a reason to visit once a week." The beat of the economic system is also the rhythm of user return — designing the quota period is designing the user's behavior cycle.
Membership expires. On the day it does, the system has to take the benefits back automatically — MemberDowngradeJob checks at midnight every day:
@Scheduled(cron = "0 0 0 * * ?") // every day at midnight
public void executeMemberDowngrade() {
// find all expired Pro/Plus users
QueryWrapper<User> qw = new QueryWrapper<User>()
.in("memberType", 1, 2) // Pro or Plus
.isNotNull("memberExpire")
.ne("userRole", "admin") // exclude admins
.lt("memberExpire", new Date()); // already expired
List<User> expiredUsers = userService.list(qw);
for (User user : expiredUsers) {
// downgrade: member type back to 0, clear expiry time
userService.lambdaUpdate()
.set(User::getMemberType, 0)
.set(User::getMemberExpire, null)
.eq(User::getId, user.getId())
.update();
// adjust space quota + send downgrade notification (more below)
}
}
Downgrade isn't "delete the account" — it's "take back the benefits": member type back to 0, expiry time cleared, space quota dropped to the Normal tier, and a "membership expired" system notification sent. A few design points:
Scheduled task + expiry field — the downgrade relies on the judgment "expiry time is earlier than now," not "today is whose expiry day." These look the same, but "downgrade on expiry" is more robust — even if the task runs a day late, it only downgrades a day late, never misses one. Judging downgrades by "expiry time" rather than "a recurring task's date" is the more reliable choice.
Exclude admins — admins' member status isn't affected by downgrade. Privileged accounts shouldn't be touched by ordinary rules — that's ops common sense.
Send a notification — the downgrade doesn't happen silently; users are told "your Plus expired, you've been downgraded to a Normal User." Changes to benefits must be proactively announced, or users will suddenly discover "my image-generation allowance is gone" and be confused, thinking it's a bug.
The downgrade task has more engineering details. It runs once at midnight every day, not "at the moment of expiry" — why? Because "trigger precisely at the moment of expiry" needs a real-time task (possibly a timer per user), which is expensive; a batch scan once a day at midnight has a delay of at most a day, which is acceptable. "Batch + scheduled" is the standard shape of periodic business — downgrading precisely to the second is meaningless, a day late has no impact, and batch scanning costs an order of magnitude less.
The downgrade notification uses the system notification (SystemNotify) with notifyType = "ACCOUNT_CHANGED" — an "account status changed" type of notification that lands in the user's message center. Why a system notification instead of email? Because email may land in spam or be ignored, while an in-app system notification is "something the user is guaranteed to see" (as long as they open the app). Important account notifications must be delivered where users are guaranteed to see them — in-app notifications are more reliable than email.
One more detail: on downgrade, set(User::getMemberExpire, null) — the expiry time is also cleared. Why? Because after downgrade, if the user invites again to become a member, the old expiry time must not linger and interfere with the new membership's expiry judgment. A downgrade has to "clear the state cleanly" — changing only the member type without clearing the expiry time would corrupt the next judgment.
The downgrade task also has a boundary worth mentioning: space downgrade doesn't delete data, but new uploads are blocked. After downgrade, the space quota returns to the Normal tier and users' stored data may exceed it. The frontend should check "current space quota is full" when users try to upload, block the upload, and show "space quota insufficient — clean up or upgrade." This "validate quota at upload time" logic pairs with the downgrade task's "adjust the quota" — one manages "the quota changed," the other "blocks uploads when over quota." Downgrade has to both "take back benefits" and "block new uploads" — only adjust the quota without blocking uploads, and users can still upload, making the quota meaningless.
The most humane-testing part of a downgrade is the space quota adjustment — a user stored 10GB as Plus, and after downgrade the Normal tier allows only 2GB. What to do? Delete the data? That's a disaster. This project handles it maturely:
// adjust space quota to the Normal tier
SpaceLevelEnum commonLevel = SpaceLevelEnum.COMMON;
StringBuilder detailMsg = new StringBuilder("Your Plus membership benefits have expired and you've been automatically downgraded to a Normal User.\n");
detailMsg.append("Space quota adjusted to: ").append(commonLevel.getMaxStorage()).append("MB / ")
.append(commonLevel.getMaxCount()).append(" images.\n");
// check each space for over-quota, and say so explicitly
for (Space sp : userSpaces) {
long overStorage = sp.getUsedStorage() - commonLevel.getMaxStorage();
long overCount = sp.getTotalCount() - commonLevel.getMaxCount();
if (overStorage > 0 || overCount > 0) {
detailMsg.append("「").append(sp.getSpaceName()).append("」 exceeds storage/image limits — ")
.append("existing data won't be deleted, but new image uploads are temporarily unavailable.");
}
}
detailMsg.append("Invite more friends to regain membership benefits.");
Every sentence in this code is worth savoring: "Existing data won't be deleted, but new image uploads are temporarily unavailable" — this is the golden rule of content-product quota strategy: when over quota, don't delete, just stop writes. Users' work is protected and cost is held. "Invite more friends to regain membership benefits" — the downgrade isn't the end; it's the start of the next recruiting round, turning "expired users" back into "growth drivers."
And that "over-quota detail" — the downgrade notification lists "My Album" exceeds storage by 300MB," precise to how much each space is over. The downgrade notification has to "state the impact clearly" — users need to know which spaces are over and by how much before they know what to do (clean up or re-upgrade). A vague "your membership expired" only confuses users more.
Let me also unpack one more layer of the cost logic behind "over quota but don't delete, just stop uploading": why not auto-compress the excess data or refuse the downgrade? Because the value of data lies with the user — forced cleanup enrages users and drives them away. Users stored 10GB because you were Plus; delete their data at expiry and they'll never come back. "Stop uploading" both holds the storage cost (it stops growing) and preserves users' trust (their data is still there). "Stop" is always better than "delete" for quotas — stop is a pause, delete is a rupture, and a content product can only choose stop.
That ending line — "Invite more friends to regain membership benefits" — is the finishing touch of the whole downgrade flow: it isn't merely a notification, it's a recall — pushing expired users back into the growth loop. Downgrade isn't the end; it's the start of the next invite cycle — a system designed well can turn even "user churn" into a "growth opportunity."
On the frontend, the membership system's entry points are complete: MyPage's profile card shows the current tier and badge, MemberMechanismModal explains the benefits, and InvitePage hands out invite codes and a leaderboard.
InvitePage.vue is the main battleground for recruiting:
<div v-if="inviteCode" class="yuemu-code-display">
<span class="yuemu-code-text">{{ inviteCode }}</span>
<button class="yuemu-copy-btn" @click="copyCode">Copy Link</button>
</div>
<!-- Leaderboard tab: see who invites the most -->
<button :class="{ 'yuemu-active': activeTab === 'leaderboard' }"
@click="switchTab('leaderboard')">Leaderboard</button>
The invite page has three pieces: invite code display + one-click copy (lowering the recruiting barrier to the minimum — copy, share, recruit), invite records (users see how many they've invited), and an invite leaderboard (creating competition — whoever invites more ranks higher). The leaderboard is a clever motivator: everyone has a competitive streak; seeing someone invite 20 and reach Plus while you're at 3 gives you the drive to recruit. A leaderboard amplifies "behavioral incentives" — it turns the private goal of "upgrade membership" into the social goal of "beat my friends."
MemberMechanismModal spells out the benefits: the three tiers' quotas, model multipliers, upgrade conditions. The membership benefits modal has to "explain it all in one screen" — the screen users see while hesitating "should I upgrade" decides whether they act. Write the benefits vaguely and users close it.
Another important frontend entry: the membership card on MyPage — showing the current tier (Normal/Pro/Plus) and membership badge, with a "view benefits" button beside it. This card's job is to "remind constantly": every time users glance at their profile page, they see their tier, see "I'm a Normal user," see "Pro's quota is three times mine," and the thought of upgrading gets planted again and again. The exposure of the membership system determines its conversion rate — hide the benefits entry deep, and you effectively have no membership system.
The frontend counterpart to "membership expiry": once the downgrade notification lands in the message center, users tap it and see "your membership expired — keep inviting to restore it." The frontend here has to make the "restore membership" action visible — the notification should carry a "go invite" jump so users land back on the invite page with one tap after reading. The distance from notification to action determines conversion — the downgrade notification isn't just an announcement; it's the trigger of the next invite action.
The pitfalls of this economic system, after stepping in enough of them, I got into the habit of sorting them into three buckets — "money, people, rhythm" — easier to see the pattern than a flat list.
Money: cost leaks first, then gets patched. The sign-in reward was originally too generous — one gift covered a full day of AI allowance, and heavy users freeloaded on every feature through sign-in alone, blowing the cost up. The anchor of the reward has to be costed: sign-in is a customer-acquisition cost, and the value given can't exceed what users contribute. The quota reset has tripped too: the weekly reset originally only cleared the weekly-token key and forgot the weekly image-generation key, so the image-generation quota never reset and kept accumulating for a week. And there's the seam between the two systems, "consumption" and "reset" — the last-second consumption before the reset lingered in the counter after the reset, letting users "claim a bit extra," until the time basis was unified. These three falls share one root: every "opening" in a cost system has to be watched on the ledger — miss one and the money leaks out through it.
People: any behavior-for-benefits system must be abuse-proof. The invite upgrade originally had no daily cap, and someone registered a batch of dummy accounts to invite each other, reaching Plus in a day; some filled in their own invite code with dummy accounts to self-invite; others farmed the leaderboard for rank. The countermeasures were added layer by layer: at most 5 valid invites per day, the invite code can't be your own, and the leaderboard only counts abuse-filtered valid invites. And the upgrade notification was originally written separately from the upgrade action, so sometimes users were upgraded without knowing it — benefit changes should have "action and notification as one"; upgrade a user without telling them, and the benefit is given away for nothing. The common thread of this group: once a benefit can be "farmed," its incentive is dead — and it drives real users away.
Rhythm: periodic business must have aligned beats. The space over-quota judgment originally compared only storage, not image count, so the case of "storage fine but image count over by two hundred" slipped through; the downgrade task originally only changed database fields, and users had no idea their benefits changed until they asked support and discovered their membership had expired — benefit changes must be proactively announced, not left for people to guess; the membership modal copy and the backend quota were two separate sources, so changing the quota without updating the copy left users seeing wrong information. None of these is "can't write code" — they're "forgot to align the beats." Quota judgments must cover every restricted dimension, and config and display must share one source.
When it comes down to it, nearly every pit this system stepped into shares one root: doing "money" work and forgetting to defend against "people"; doing "people" work and forgetting to align the "rhythm." Think those three relationships through, and you'll dodge half the pitfalls of an economic system — which is why I later started recording them in categories instead of as a flat list.
Pull this chain together and you'll find it's a complete "growth + monetization" loop:
User registers (with invite code)
→ bound to inviter → invite record created → inviter upgraded (anti-abuse: 5/day)
→ membership tier sets AI token quota (5-hour/week/image-gen/image-search/image-analysis)
→ user uses AI → spends tokens → hits the quota line → wants to upgrade → invites / signs in
→ sign-in gifts tokens (retention) → weekly reset on Monday (predictable cost + return hook)
→ membership expires → auto-downgrade (take back benefits + space adjustment + notification + "keep inviting to restore")
Every link feeds the next: invites acquire users, new users upgrade their inviters, upgrades bring bigger AI consumption, consumption hits the quota, the quota drives invites and sign-ins, sign-ins retain, expiry and downgrade pull people back to invite. This isn't a few isolated features — it's a self-recycling economic system — solving four problems at once: acquisition (invites), retention (sign-in), cost (quota), and revenue (the motive to upgrade).
Three sentences to sum up this article:
The quota is the anchor; tier it by cost. AI costs differ hugely, so the quota is split by feature dimension (image-gen/search/analysis each independent), and the membership tier decides the size of the quota — cost control follows the per-unit cost.
Invites are the growth engine; sign-in is the retention engine. Trading invites for membership is the cheapest growth lever (motivating both recruiting and upgrading), but it must be abuse-proof (capped at 5 per day); sign-in trades low-cost tokens for daily active users.
Reset and downgrade are the economy's beat. Every Monday resets the quota (predictable cost + return hook); on expiry, auto-downgrade (take back benefits, adjust space, notify, "keep inviting to restore") — the beat of the data makes the whole system turn in rhythm.
To be honest: an indie site's "revenue" has no shortcut — it needs a system that strings together cost, growth, and retention. This project's answer is "invite-to-earn membership + AI quota anchored to cost," which isn't right for every site, but the idea of "turning real cost into benefits users can feel" is something every indie site should have. I hope this article gives you a template to reference.
If you're also building an indie site with AI features, check yourself against this chain: does your AI cost have a quota anchor? Is your membership "bought" or "earned"? Does your quota reset periodically? Does it auto-downgrade and notify on expiry? These four questions are the dividing line between an economic system that's a "membership thrown together" and one that's "a set of wheels that turn." When building an economic system, don't rush to set a price — think through every link of the loop first: where acquisition comes from, how cost is controlled, why users stay, what happens at expiry — then get your hands dirty.
Of course, an economic system isn't a panacea — it can't stop every problem, and it can't substitute for the product's own value. But it gives an indie site a structure to "survive": acquisition, retention, cost, revenue — four gears biting each other and turning. Many indie sites die not because the product is bad, but because they lack these gears — users come and don't stay, cost rises and can't be controlled, and finally there's nothing left. Getting the economic system right is the door between "surviving" and "living well" for an indie site. I hope that when you build yours, you don't just make the features — you install this set of gears too. First make the site turn; then make the money turn; only then is the site truly standing.