The One-Person Company: Designing and Building the Payment System
The world bustles for gain; the world hustles for profit. — Sima Qian, Records of the Grand Historian
Author’s note: Today is the seventh day of the Lunar New Year, the last day of the holiday. I hope everyone has enjoyed a relaxed and happy Spring Festival in the Year of the Horse! The Programmer wishes you all a joyful New Year—may everything go smoothly and success come swiftly. This is the ninth installment of the “One-Person Company” series. After the finale, I couldn’t resist picking up my pen once more to record a new journey. In the first seven essays we built a complete tech stack from infrastructure to product application; the eighth was a closing summary discussing the business model and customer-acquisition strategy. But one key piece was always missing: how do you collect money?
The invitation system solved “who gets in.” Now it’s time to solve “how they pay once they’re in.” If a SaaS product can’t charge, it will forever remain a side project.
Note: This essay uses Stripe’s services. I personally consider Stripe a great technology company—its API design is exquisite, its test environment is thorough, and it is a reference standard for building SaaS. If you’re building services overseas, or running internet services abroad that involve payments, Stripe is the first choice for taking that first step.
But using Stripe doesn’t mean payments will be smooth sailing—especially for a “one-person company” like ours with grand “ambitions,” eager to run several businesses (or pivot quickly). That’s why we summon the operon framework and build a standalone billing service.
1. Why Do We Need a Standalone Billing Service?
When building Ligand (the invitation service) in the previous essay, I already tasted the sweetness of a “standalone service.” Applying the same logic to billing, the reasons are even more compelling:
First, billing logic is ten times more complex than you think. You assume payment means “collect money → unlock feature”? In reality you have to handle: subscription upgrades and downgrades, billing period rollovers, credit deduction and refunds, shared team pools, idempotent processing of Stripe webhooks, graceful degradation on payment failure… Cramming all these into every app is asking for trouble.
Second, Stripe integration needs centralized control. Webhook secrets, API keys, Price IDs—sensitive configurations scattered across apps are a security risk. Consolidate them into one service: only the billing service talks to Stripe, and apps talk only to the billing service. The attack surface narrows, and security goes up sharply.
Third, the second product won’t have to reinvent the wheel. Today Slides needs to charge; tomorrow my second product will too. If the billing logic lives in Slides, the new product must either copy-paste or start over. With a standalone billing service, a new product just registers and it’s ready to use.
So the answer is the same as with Ligand: a standalone service, multi-tenant, integrated via API.
2. ATP: A Multi-Tenant Billing Service
I named this service ATP (Adenosine Triphosphate), continuing the biology-naming tradition. In cells, ATP is the “energy currency”—almost every energy-requiring reaction is driven by ATP. In our context, credits are the application’s “energy currency”; every time a user runs an AI generation operation, they consume one unit of ATP. The name fits perfectly.
Design Principles
The core design philosophy of ATP is four words: centralized settlement.
First, user-facing paid apps never interact with Stripe directly. ATP is the only service that talks to Stripe. The app wants to create a Checkout session? Call ATP’s API. The app wants to check a user’s balance? Call ATP’s API. This guarantees all payment logic lives in one place—and changes happen in one place.
Second, credits are the unified unit of account. Whatever the app sells—image generation, document translation, or code analysis—it all converts into “credits.” One image generation costs 1 credit, one style generation costs 1 credit. Each app configures its own CostTable. Unified metering, flexible pricing.
Third, multi-tenant but isolated. Each app is a tenant of ATP, with its own subscription plan configuration, credit pack definitions, and Stripe product mapping. Data between apps is fully isolated, yet they share the same billing engine.
Subscription Tiers
ATP defines three tiers covering the typical SaaS pricing model:
Free → Pro → Team
- Free: A small allotment of credits each month, limited features, cannot purchase credit packs
- Pro: More credits each month, all features unlocked, can purchase credit packs on demand
- Team: The highest allotment, shared team pool, supports multi-person collaboration
The specific limits of each tier are defined by the app itself:
struct TierLimits {
monthly_credits: u32, // monthly allotment of credits
max_projects: Option<u32>, // maximum number of projects
allowed_visibility: Vec<String>,// permitted visibility settings
can_purchase_credits: bool, // whether credit packs can be purchased
team_features: bool, // whether team features are enabled
}
For example, Slides might configure 20 credits per month for Free, 500 for Pro, and 2,000 for Team—while another app might use entirely different numbers. One engine, a thousand faces.
Here’s the finished product for you:

Buying credits:

Managing/upgrading a plan:

3. The Credit System: Accounting That Looks Simple
Credits are ATP’s core abstraction. It is a double-entry accounting system: CreditBalance holds the current snapshot, and CreditLedger records every transaction detail. This isn’t over-engineering—it’s the basic literacy of any system involving “money.”
Three Sources of Credits
A user’s credits come from three pockets:
Subscription Credits are the monthly quota. At the start of each billing cycle, Stripe’s invoice.paid webhook triggers a credit refresh. Unused credits do not roll over—unused means lost. This is the classic “use it or lose it” model.
Purchased Credits are credit packs users buy on top. These never expire—it’s the user’s “savings account.”
Team Pool is an exclusive capability of the Team tier. Credits purchased by the team admin flow into a shared pool, from which all members can draw.
Deduction Priority
When a user performs a consuming operation (say, generating an image), ATP deducts in a fixed priority order:
Subscription Credits → Purchased Credits → Team Pool
Why this order? Because subscription credits reset to zero at month-end—use them or lose them; purchased credits never expire—keeping them is most economical; the team pool is a shared resource—tap it last. This is the deduction strategy most favorable to the user.
The core deduction logic:
pub async fn debit(
db: &DynamoClient, email: &str, app_id: &str,
amount: u32, action: &str, related_id: Option<&str>,
) -> Result<DebitResult, AppError> {
let user_balance = credit_repo::get_user_balance(db, email, app_id).await?;
let user_total = user_balance.as_ref().map_or(0, |b| b.total());
// 1. User has enough of their own credits; deduct directly
if user_total >= amount as i32 {
return credit_repo::debit_user_credits(
db, email, app_id, amount, action, related_id
).await;
}
// 2. Not enough; check the team pool
let binding = user_repo::get_binding(db, email, app_id).await?;
let team_id = binding.as_ref()
.and_then(|b| b.team_id.as_deref())
.ok_or_else(|| AppError::BadRequest(
format!("insufficient credits: have {user_total}, need {amount}")
))?;
let team_balance = credit_repo::get_team_balance(db, team_id, app_id).await?;
let team_available = team_balance.as_ref().map_or(0, |b| b.purchased_credits);
if user_total + team_available < amount as i32 {
return Err(AppError::BadRequest(format!(
"insufficient credits: have {} (user: {}, team: {}), need {}",
user_total + team_available, user_total, team_available, amount
)));
}
// 3. Drain the user's credits first; cover the shortfall from the team pool
let from_user = user_total.max(0) as u32;
let from_team = amount - from_user;
// ... deduct from each, return DebitResult
}
DebitResult tells the caller the source breakdown of each deduction:
pub struct DebitResult {
pub amount: u32, // total deducted
pub from_subscription: u32,// from subscription credits
pub from_purchased: u32, // from purchased credits
pub from_team_pool: u32, // from the team pool
pub tx_id: String, // transaction ID
}