UserCredits - Simplify Pay-As-You-Go Features for Your Project
As an entrepreneur working on CvLink, I understand the challenges of adding payment and pay-as-you-go features to a project. It's a time-consuming process, and I wanted to build a library in public, documenting the reasons behind my decisions and giving readers the opportunity to provide feedback early in the process.
The library is hosted on GitHub.
While there are existing solutions like LemonSqueezy for implementing pay-as-you-go features, they can be overly complex for simple needs. You may just want to allow users to track their token consumption and pay for them, either before or after usage, without the complexity and cost of a full-fledged service.
What's Typically Needed
To better understand the scope, let's summarize the essential requirements:
- Tokens: An abstraction of money to decouple from real currency. For example, users can buy 10 tokens for $10 or 20 tokens for $18.
- Real-time tracking of token consumption or balance.
- A way for customers to pay for the tokens they've used or to purchase tokens upfront (depending on your billing model).
- Currency conversion to display token offers in multiple currencies.
- The ability to refund customers for unused tokens.
Concepts
Taking a straightforward approach, here are the core concepts:
-
Offers: Represent how many tokens users get for their money. This includes scenarios like one-time offers, monthly subscriptions, yearly subscriptions, and discounts based on subscriptions. Offers can also override others with the same
overridingKey
. - Orders: Represent customers' intentions to purchase an offer. We track their status until the payment is successful.
- User Credits: Provide information about a user's token balance and consumption.
- Token Timetable: A detailed log of each token consumption, including the date, time, and the service used.
This seems to cover all the essentials for implementing a pay-as-you-go feature in a startup.
Technologies
Projects differ in their database and payment choices, so we expect the need for adapters on these axes. Therefore, it's crucial to keep concepts separate from implementations.
Testing scenarios for buying, consuming, and checking credit balances should be dissociated from the specific technologies used, making them adaptable to different platforms.
Implementation
Here's how I've implemented these concepts:
Offer
An offer specifies its type (e.g., one-time offer or subscription) and a repetition cycle. The parentOffer
property specifies relationships, such as discounts for subscribers. Offers with the same overridingKey
value override each other.
Multiple sub-offers can share the same overridingKey
, representing different discount levels. For example, a free user could buy 10 AI credits for the regular price of $10, a "Starter" user could get a 30% discount, and a "Early adopter" user could have a 50% discount. These offers all have the same overriding key, e.g., overridingKey="10_AI_Credits"
:
- The $10 offer at the root level (
parentOffer=null
) for default visibility. - The 30% off offer, a child of the "Starter" offer with a
weight=1
. - The 50% off offer, a child of the "Early adopter" offer with a
weight=2
.
This way, an "Early adopter", subscribed as a "Starter", gets better privileges by paying 50% less for AI tokens; while keeping the access rules associated to "Starter" accounts.
export interface IOffer {
cycle: "once" | "weekly" | "monthly" | "yearly";
kind: "subscription" | "tokens" | "expertise";
name: string;
parentOffer: unknown;
price: number;
tokenCount: number;
weight: number;
}
Order
Orders are tied to users intending to purchase an offer. We track their status until payment success. The status
field is duplicated to simplify order reading, and the token count is duplicated for robustness.
export interface OrderStatus {
date: Date;
message: string;
status: "pending" | "paid" | "refused";
}
export interface IOrder<K extends object> {
history: [OrderStatus];
offerId: K;
status: "pending" | "paid" | "refused";
tokenCount: number;
userId: K;
}
Token Timetable
Token consumption and additions are logged in the token timetable for users to track their token activity.
export interface ITokenTimetable {
createdAt: Date;
tokens: number;
userId: string;
}
User Credits
User credits summarize all transactions, including subscriptions and token balances. Subscriptions duplicate order status for efficient reading.
export interface ISubscription {
expires: Date;
offerId: unknown;
starts: Date;
status: "pending" | "paid" | "refused";
}
export interface IUserCredits {
subscriptions: (unknown extends ISubscription ? unknown : never)[];
tokens: number;
userId: unknown;
}
Service
The payment interface serves as a facade to manage these concepts. Customers can create orders, execute payments, and check their token balance.
export interface IPayment<T extends object> {
createOrder(offerId: unknown, userId: unknown): Promise<IOrder<T>>;
execute(order: IOrder<T>): Promise<IUserCredits>;
orderStatusChanged(
orderId: unknown,
status: "pending" | "paid" | "refused",
): Promise<IOrder<T>>;
remainingTokens(userId: unknown): Promise<IUserCredits>;
}
Implementation
- MongoDB was chosen for the initial implementation, but migrating to other databases will be straightforward.
- The payment part is yet to be implemented, with Stripe as the first choice, but flexibility for other implementations.
- Screens will be developed using SvelteKit, but the library is designed to be adaptable to React, Angular, and Vue as well.
I welcome early contributors to this project, so feel free to contact me if you'd like to contribute or provide feedback.
Thank you for your interest in the UserCredits library! 🚀
Top comments (0)