When you hear "learning platform," you might imagine a simple app that just lines up videos and records progress. But when you actually build a subscription product that survives production, most of the hard parts concentrate in the complexity of the billing domain.
The subject is a subscription learning platform for financial-literacy education (not a venue that recommends investing or trading, but one that offers "learning about money"). A learner-facing web app and an operator-facing admin dashboard were built as a pnpm + Turborepo monorepo (3 apps, 14 shared packages), where users from multiple channels — direct sales, agencies, NFT-holder perks, and migration from external platforms — mix with different prices, perks, and payment methods. As one of the core full-stack engineers on a multi-person team, I implemented across the front end, the admin screens, subscriptions/payments, commissions, the DB schema, and authentication.
This article has one rule. The single source of truth is real code. Not pretty slides, but actual running TypeScript / Prisma — extracting and explaining only the parts that become material for an enterprise to judge "with this design, we can leave it to them."
Premise on the numbers: the quantitative values in the text (433 tests, 93 migrations, 72 models, 14 packages, 9 Cron jobs, etc.) are all measured values mechanically counted from the repository. Business ROI like membership count, revenue, and retention rate is not covered, since it requires the client's real data. The policy is: don't fabricate.
1. The Whole System Picture: Monorepo and Trust Boundaries
First, the structure. It's made of 3 apps — apps/front (learners), apps/admin (operators), apps/sample-app (scaffold) — and 14 @workspace/* packages.
apps/
front/ … 受講者向け Next.js 16(App Router / RSC / Mantine)
admin/ … 運営管理ダッシュボード(サーバーアクション中心)
packages/
subscription/ … 料金解決・NFT特典・銀行振込の状態機械(純粋関数)
commission/ … 代理店コミッションの追記専用台帳
auth/ … サインイン・OTP・tokenVersion
session-manager/… JWT セッション(jose / __Host- Cookie)
mirai-teracy-prisma/ … Prisma スキーマと Repository
never-error/ ui / logger / mail / line-messaging / …
The tech stack is Next.js 16.1 / React 19 / TypeScript 5 / Prisma 7 / PostgreSQL / Mantine 8 / Zod / Stripe 17. Hosting is Vercel (region sin1).
A point to make clear about the design: this product uses neither Supabase nor RLS. Authorization is enforced not at the DB row level but at the app layer — requireAdmin() at the entrance of server actions, and the actorUserId handed to use cases. Instead, the audit logs that must not be tampered with are also protected by the DB's foreign-key constraint (onDelete: Restrict), as described later. The point is that "where to draw the trust boundary" is chosen explicitly.
turbo.json's task graph has build depend on ^build (building dependency packages) and typecheck, and the aggregate gate ci-check runs typecheck → build → test → lint → format:check in one go. It structurally enforces the order in which you can't build if the types don't pass.
2. Folding Multi-Channel Pricing into "a Single Pure Function"
The trickiest thing to design in this product is the pricing scheme. The same product is used by
- bank-transfer users (not via Stripe)
- monthly/yearly cohorts originating from an external platform
- student lists and the Alpha cohort
- NFT holders (12 tiers of perks, old and new combined)
- direct-sale users who are none of the above
each with different prices, free-perk periods, and purchasable plans. Scatter this with ifs across the UI or API handlers, and a missed branch will inevitably become an incident.
So I folded pricing resolution into a single side-effect-free pure function. It's resolvePricingClassification in packages/subscription/src/pricing-classification/resolve.ts.
export const resolvePricingClassification = (
input: PricingClassificationInput
): PricingClassification => {
const parsed = pricingClassificationInputSchema.parse(input); // 境界で Zod 検証
const { nftEntitlement, nftPurchasedAt, idMatches } = parsed;
const refinedEntitlement = refineEntitlementByIdAndDate(
nftEntitlement, nftPurchasedAt, idMatches,
);
// 優先順位を固定した「決定木」。上から順に最初に一致したものを返す。
if (idMatches.inBankTransfer) {
return { flow: "BANK_TRANSFER_SKIP", bucket: null, availablePlans: [], /* … */ };
}
if (idMatches.inMonthlyPayer) {
return { flow: "UTAGE_MONTHLY_SKIP", bucket: null, availablePlans: [], /* … */ };
}
if (idMatches.inYearlyPayer) {
return { flow: "STRIPE_CHECKOUT", bucket: "K01", availablePlans: ["yearly"], /* … */ };
}
if (idMatches.inStudentList || idMatches.inAlpha) {
return { flow: "STRIPE_CHECKOUT", bucket: "K02", availablePlans: ["monthly", "yearly"], /* … */ };
}
if (idMatches.inOldProLate) {
return { flow: "STRIPE_CHECKOUT", bucket: "K02", availablePlans: ["monthly", "yearly"], /* … */ };
}
if (refinedEntitlement === "NEW_LIGHT" || refinedEntitlement === "OLD_LITE") {
return { flow: "STRIPE_CHECKOUT", bucket: "K13", availablePlans: ["monthly"], /* … */ };
}
return { flow: "STRIPE_CHECKOUT", bucket: "K13", availablePlans: ["monthly", "yearly"], /* … */ };
};
There are 3 design points.
- The input is normalized to 6 decision flags + the NFT perk before being passed in. The pricing function has no side effects at all, like "querying the DB" or "looking at the session"; input → output is deterministic. So every branch can be unit-tested without a DB.
- The output is not a raw Stripe Price ID, but a pricing bucket (
K01/K02/K13) + the payment flow + the purchasable plans. The price itself is resolved in a single downstream place (bucket → price version → Stripe Price ID). By not returning the price directly, the concerns of "deciding the pricing" and "the actual charge amount" are separated. - The priority order reads top-down. Business rules — bank transfer skips with top priority, the external-migration cohort doesn't go through Stripe — become the very order of the code.
It's also deliberate that I express a value representing state, like flow, not with a TypeScript enum but with a Union type ("STRIPE_CHECKOUT" | "BANK_TRANSFER_SKIP" | …). This choice pays off in the exhaustiveness checking (NeverError) discussed later.
3. Resolving NFT Perks: Deterministically Choosing the Longest Free Period
An NFT holder may hold multiple NFTs. Make "which perk to apply" ambiguous, and it tilts advantageously/disadvantageously per user, becoming unfair. So I prepared a pure function that chooses "the perk with the longest free period" and further tie-breaks deterministically on ties.
const isBetterCandidate = (candidate: ScoredHolding, incumbent: ScoredHolding): boolean => {
// 1) 無料月数が長いほうを優先
if (candidate.freeMonths !== incumbent.freeMonths) {
return candidate.freeMonths > incumbent.freeMonths;
}
// 2) 同点なら購入日のランクで決定的に
const candidateRank = purchasedAtRank(candidate.purchasedAt);
const incumbentRank = purchasedAtRank(incumbent.purchasedAt);
if (candidateRank !== incumbentRank) return candidateRank > incumbentRank;
// 3) それでも同点なら enum 文字列で安定ソート(毎回同じ結果に収束)
return candidate.rawEntitlement < incumbent.rawEntitlement;
};
The LIFETIME_FREE perk is treated as Number.POSITIVE_INFINITY months so it surely comes to the top. By deciding the tie-break all the way down to "purchase date → tier string," I guarantee the property of the same output for the same input — i.e., something fixable in tests.
The legacy OLD_PRO tier is refined to EARLY / LATE at a purchase-date boundary.
export const OLD_PRO_PURCHASE_DATE_BOUNDARY = new Date("2026-02-01T00:00:00+09:00");
export const refineEntitlementByPurchaseDate = (
entitlement: NftEntitlement | null,
purchasedAt: Date | null,
): NftEntitlement | null => {
if (entitlement !== "OLD_PRO") return entitlement;
if (purchasedAt === null) return "OLD_PRO"; // 購入日不明なら refine しない
return purchasedAt.getTime() < OLD_PRO_PURCHASE_DATE_BOUNDARY.getTime()
? "OLD_PRO_EARLY"
: "OLD_PRO_LATE";
};
The real-world constraint that the external NFT-holding-check API doesn't yet return purchasedAt is bridged by an override table explicitly named "provisional." Because the intent "delete this once the API is fixed in the future" remains in the schema, the technical debt doesn't silently become permanent.
4. An Idempotent, Ordering-Resistant Stripe Webhook
Stripe Webhooks have 2 troublesome properties that divide production quality. They're delivered "at least once" (= the same event arrives multiple times), and they can arrive out of order (= an old event arrives after a new one). Process them naively, and double-processing or subscription-state rollbacks occur.
Layer 1: Absorb Resends with the Event-ID Unique Constraint
model StripeWebhookEvents {
id String @id @default(uuid())
stripeEventId String @unique // ← 同じイベントは 2 行目を作れない
eventType String
processedAt DateTime?
rawPayload Json
// …
}
Layer 2: Reject Order Reversal by Comparing event.created
The subscription row holds "the creation time of the last processed event," and skips an event older than that. Without this, when events like the Phase-1/Phase-2 of a pause arrive out of order, the subscription unintentionally rolls back to an old state.
// apps/front/src/lib/stripe/webhook-handlers/subscription-updated.ts
if (
existingSubscription.lastProcessedEventCreatedAt !== null &&
event.created < existingSubscription.lastProcessedEventCreatedAt
) {
return {
success: true,
message: `Skipping stale event ${event.id} (event.created=${event.created} < last=${existingSubscription.lastProcessedEventCreatedAt})`,
};
}
Layer 3: Recursively Redact PII Before Saving
Stripe's raw payload contains PII like card info, email, IP, name, and address. We want to save it for debugging, but we don't want to keep the PII. So, before saving, recursively replace PII keys with <redacted>.
const PII_KEYS: ReadonlySet<string> = new Set([
"billing_details", "shipping", "customer_email", "customer_phone", "address",
"email", "phone", "name", "card", "us_bank_account", "sepa_debit",
"fingerprint", "last4", "exp_month", "exp_year", "wallet",
"client_ip", "client_secret", "ip_address", "user_agent", /* … */
]);
const redactPii = (value: unknown): RedactedJson => {
if (value === null || typeof value === "string" ||
typeof value === "number" || typeof value === "boolean") {
return value;
}
if (Array.isArray(value)) return value.map(redactPii);
if (typeof value === "object") {
const result: { [key: string]: RedactedJson } = {};
for (const [key, val] of Object.entries(value)) {
// PII キーは中身を見ずに丸ごと "<redacted>" へ。それ以外は再帰。
result[key] = PII_KEYS.has(key) ? "<redacted>" : redactPii(val);
}
return result;
}
return "<null>";
};
Implemented in a blacklist style (enumerate the keys to hide) while recursively traversing nested structures, so deep PII like charges.data[].billing_details.address is also redacted. The point is that "save it, but don't keep the PII" is guaranteed by code, not by a caution in log operations.
5. Payments Not on Stripe: The State Machine of Bank-Transfer Subscriptions
Card payments are concentrated on Stripe, but recurring bank-transfer billing is outside Stripe. You must correctly transition payment deadlines, grace periods, dunning, and auto-expiry yourself. Write this as a clump of ifs and it will surely collapse, so I made the mapping of elapsed days → status a pure function.
// packages/subscription/src/bank-transfer/state-machine.ts
export const GRACE_PERIOD_DAYS = 7;
export const CANCELED_THRESHOLD_DAYS = 30;
const resolveNextStatus = (
currentStatus: BankTransferSubscriptionStatus,
daysSinceExpiry: number,
): BankTransferSubscriptionStatus => {
if (currentStatus === "CANCELED") return "CANCELED"; // 終端は不可逆
if (daysSinceExpiry < 0) {
return currentStatus === "REACTIVATED" ? "REACTIVATED" : "ACTIVE";
}
if (daysSinceExpiry < GRACE_PERIOD_DAYS) return "GRACE_PERIOD"; // 0–6 日
if (daysSinceExpiry < CANCELED_THRESHOLD_DAYS) return "SUSPENDED"; // 7–29 日
return "CANCELED"; // 30 日〜
};
The judgment of whether to send a dunning email is also split out into another pure function, made idempotent via a containment check against a "sent set." This way, even if the daily Cron runs twice on the same day, it doesn't double-send the same dunning.
const REMINDER_THRESHOLDS = [
{ stage: "T_MINUS_0", thresholdDays: 0 },
{ stage: "T_MINUS_7", thresholdDays: 7 },
{ stage: "T_MINUS_14", thresholdDays: 14 },
{ stage: "T_MINUS_30", thresholdDays: 30 },
] as const;
export const selectReminderToSend = (
input: ReminderSelectionInput,
): BankTransferReminderStage | null => {
if (input.currentStatus === "CANCELED") return null;
if (input.currentStatus === "GRACE_PERIOD") {
// 既に送っていれば null(冪等)
return input.remindersSent.has("OVERDUE_GRACE") ? null : "OVERDUE_GRACE";
}
if (input.currentStatus === "SUSPENDED") return null;
const currentStage = findCurrentStage(/* daysUntilExpiry */);
if (currentStage !== null && !input.remindersSent.has(currentStage)) {
return currentStage;
}
return null;
};
Because the design passes "the time" and "the sent flags" as arguments from outside, you can test with any date and any send history as input. Confining time-dependent logic like a state machine into a pure function is the standard.
6. Agency Commissions: An Append-Only Ledger and Integer Arithmetic
Reward calculation for agencies (affiliates) is money math, so no compromise is permitted. The requirements were "make the monthly batch at-least-once (re-execution safe)," "with no rounding error," and "carry over amounts below the minimum payout to the next month."
An Append-Only Ledger with an Idempotency Key
Each ledger entry is uniquely identified by an idempotency key determined from scope, period, currency, and revision. Monthly recalculation only increments the revision, so no matter how many times you run the same batch, no double-counting occurs.
// packages/commission/src/usecase/ledger/idempotency.ts
export const buildLedgerIdempotencyKey = (input: {
readonly scope: CommissionScope; // partner:<id> / user:<id>
readonly periodYearMonth: number; // 例: 202606
readonly currency: CommissionCurrency;
readonly revision: number;
}): string => {
if (!Number.isInteger(input.revision) || input.revision < 0) {
throw new RangeError(`revision must be a non-negative integer; got ${input.revision}`);
}
const scopeSegment = scopeToSegment(input.scope);
return `${scopeSegment}:${input.periodYearMonth}:${input.currency}:r${input.revision}`;
};
model CommissionLedgerEntry {
// …
payableMinor Int @map("payable_minor")
carryOverFromPreviousMinor Int @default(0) @map("carry_over_from_previous_minor")
carryOverToNextMinor Int @default(0) @map("carry_over_to_next_minor")
/// 形式: `${scopeKey}:${ym}:${currency}:r${revision}`。同月再計算は revision を増やす。
idempotencyKey String @unique @map("idempotency_key")
}
BigInt Basis-Point Arithmetic That Excludes Floating Point
The reward rate is held as an integer in basis points (10000 = 100%), the calculation is BigInt, and storage is "an integer in minor units (1 yen for yen)." It structurally avoids Decimal's instability and float's rounding error.
// packages/commission/src/rate/calcReward.ts
const BASIS_POINTS_DENOMINATOR = 10_000n;
export const calcReward = (input: CalcRewardInput): CalcRewardResult => {
const grossBig =
(BigInt(input.monthlySalesMinor) * BigInt(input.rateBasisPoints)) /
BASIS_POINTS_DENOMINATOR;
const totalBig =
grossBig + BigInt(input.upperAgentBonusMinor) + BigInt(input.carryOverInMinor);
const minimumBig = BigInt(input.minimumPayoutMinor);
if (totalBig >= minimumBig) {
// 支払う:繰り越しは 0
return { grossRewardMinor, payableMinor: Number(totalBig), carryOverOutMinor: 0 };
}
// 最低額に満たない:今月は払わず、全額を翌月へ繰り越す
return { grossRewardMinor, payableMinor: 0, carryOverOutMinor: Number(totalBig) };
};
"Re-execution safe (idempotent)," "zero rounding error (integer arithmetic)," and "carry over the remainder" — all 3 properties needed for a batch that handles money are expressed with types and pure functions.
7. The Details of Authentication: Enumeration Defense, Bulk Revocation, OTP
Constant-Time Sign-In That Suppresses Account Enumeration
We make it so "is this email registered?" can't be inferred from the difference in response speed (a timing oracle). Even when the user doesn't exist, a dummy-hash bcrypt comparison is always run, keeping the processing time constant.
// packages/auth/src/auth.ts
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(randomUUID(), DEFAULT_SALT_ROUNDS); // 12 rounds
async signIn({ email, password }: SignInInput): Promise<boolean> {
const normalizedEmail = normalizeEmail(email);
const user = await this.userDataSource.findUserByEmail(normalizedEmail);
// 未知ユーザーでも比較は必ず走らせる(早期 return で時間差を作らない)
const passwordHash = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
const isValid = await bcrypt.compare(password, passwordHash);
if (!user || !user.passwordHash || !isValid) return false;
if (user.otpLockedUntil && user.otpLockedUntil.getTime() > Date.now()) return false;
await this.sendSignInOtp({ userId: user.id, email: normalizedEmail });
return true;
}
On paths that "tend to branch on existence," like requesting a password-reset link, I further interpose a response-time floor (wait if it falls short of a fixed time) to even out the time difference.
private async enforceTimingFloor(startedAt: number): Promise<void> {
const remaining = this.passwordResetTimingFloorMs - (Date.now() - startedAt); // 既定 600ms
if (remaining <= 0) return;
await new Promise<void>((resolve) => { setTimeout(resolve, remaining); });
}
Bulk-Revoke All JWTs with tokenVersion
The session is a stateless JWT (jose / __Host--prefixed Cookie). The weakness of stateless is "you can't immediately revoke an individual token," but I solve that with tokenVersion (claim name v). Carry the user's generation number in the JWT, and advance the DB-side generation at a timing like a password change, and tokens with the old v all become invalid at once.
// packages/session-manager/src/session-manager.ts
const sessionPayloadSchema = z.object({
sub: z.string(),
emailVerified: z.boolean().optional(),
otpVerified: z.boolean().optional(),
sid: z.string().optional(),
v: z.number().optional(), // ユーザー世代(強制ログアウト用途)
});
The password update is contractually obligated to return a new tokenVersion in the same transaction, satisfying the natural expectation that "change your password and you're logged out from all devices" while staying a stateless JWT.
OTP Is HMAC-SHA256, 5 Minutes, 5-Attempt Lockout
The two-factor one-time code is not stored in plaintext. It's hashed and stored with a secret-keyed HMAC-SHA256, and brute force is suppressed with an expiry, attempt count, and lockout.
// packages/auth/src/otp/constants.ts
export const OTP_LENGTH = 6;
export const OTP_TTL_SECONDS = 60 * 5; // 5 分
export const OTP_MAX_ATTEMPTS = 5;
// packages/auth/src/otp/otp-utils.ts
export const hashOtpCode = (code: string, secret: string): string =>
createHmac("sha256", secret).update(code).digest("hex");
Get it wrong 5 times and otpLockedUntil is set 1 hour ahead, locking it. The code is single-use via consumeOtp, also preventing replay.
8. Hardening the Domain with Types: Exhaustiveness Checking via NeverError
I've repeated "represent state with Union types" up to here. The aim is to turn missed switch branches into compile errors. The key is the small class in @workspace/never-error.
// packages/never-error/src/index.ts
export class NeverError extends Error {
public override readonly name = "NeverError";
constructor(value: never, message?: string) {
super(message ?? `Unhandled discriminant value (exhaustive check failed): ${stringifySafely(value)}`);
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, NeverError);
}
}
}
The usage is this. Once you've handled all the cases of a switch, the type of the value reaching default is narrowed to never. If you add a new pricing flow to the Union but forget to update the switch, that value is no longer never, and new NeverError(flow) becomes a compile error.
function describe(flow: PricingFlow): string {
switch (flow) {
case "STRIPE_CHECKOUT": return "Stripe で課金";
case "BANK_TRANSFER_SKIP": return "銀行振込(Stripe を通さない)";
case "UTAGE_MONTHLY_SKIP": return "外部移行コホート";
default:
// flow を増やしてここを更新し忘れると、ここで型エラーになる
throw new NeverError(flow);
}
}
This project bans by convention as (type assertion), any, ! (non-null), and enum. Avoid enum, use a Union of as const satisfies, and guarantee exhaustiveness at compile time with NeverError — a mechanism by which "missed branches" don't reach production even when multiple people touch a complex billing domain. Boundaries (API input, Webhook payloads, CSV rows) are verified with Zod, and only trustworthy types flow inside.
9. The Use-Case Layer and Atomic Audit Logs
Admin operations (suspending an agency, etc.) must always be established as a set of "the business write" and "the audit log." Crash midway and leave only one, and the audit trail becomes a lie.
So a use case receives a Prisma.TransactionClient as its first argument and doesn't open a transaction itself. It performs the business write and the record into AdminAuditLogs atomically in the same transaction.
// apps/admin/src/lib/usecases/partner-admin/suspend-partner.ts
export const suspendPartner = async (
transaction: Prisma.TransactionClient, // ← トランザクションは外から注入
input: SuspendPartnerInput,
deps: SuspendPartnerDeps,
): Promise<void> => {
const partnerRepo = new PartnerRepository(transaction);
const before = await partnerRepo.findById(input.partnerId);
if (before === null) throw new Error(`Partner not found: ${input.partnerId}`);
const after = await partnerRepo.suspend(input.partnerId);
await recordAuditLog(transaction, { // ← 監査ログも同じ tx で
actorUserId: input.actorUserId,
action: "partner_status_update",
targetType: "partner",
targetId: input.partnerId,
payload: { before: before.status, after: after.status },
});
};
The transaction boundary, cache revalidation, and authorization are owned by the server-action side. It always passes through requireAdmin() at the entrance and hands actorUserId to the use case.
// apps/admin/src/app/(authenticated)/partners/[id]/actions.ts
export const suspendPartnerAction = async (partnerId: string): Promise<void> => {
const admin = await requireAdmin(); // ← 認可はアクション境界で
await prismaClient.$transaction((tx) =>
suspendPartner(tx, { partnerId, actorUserId: admin.id }, { /* deps */ }),
);
revalidatePath(`/partners/${partnerId}`); // ← 再検証もアクションが所有
};
The immutability of the audit log is protected not only by the app layer but also by the DB's constraints. Because the foreign key of AdminAuditLogs.actor is onDelete: Restrict, an admin who has left an audit trail cannot be physically deleted. It's a design where "it got deleted by an app bug" doesn't happen.
10. The Idempotency and Input Validation of CSV Import
Operators upload ID lists by CSV for yearly members, bank transfers, and cohort decisions. So that uploading the same file twice causes no accident, it's made idempotent by (uploader, SHA-256 checksum, list type).
// 同一ファイルの再アップロードは no-op(ALREADY_IMPORTED)
const existingHistory = await deps.prisma.idListUploadHistories.findUnique({
where: {
actorUserId_checksumSha256_listType: {
actorUserId: input.actorUserId,
checksumSha256: input.checksumSha256,
listType: input.listType,
},
},
});
if (existingHistory !== null) {
return { ok: true, outcome: "ALREADY_IMPORTED", history: existingHistory };
}
The checksum is computed at upload time.
const buildImportArtifact = async (file: File): Promise<ImportArtifact> => {
const buffer = Buffer.from(await file.arrayBuffer());
return {
checksum: createHash("sha256").update(buffer).digest("hex"),
csvText: buffer.toString("utf8"),
safeFilename: sanitizeUploadFilename(file.name),
};
};
Because it's an external upload, we defend in multiple layers on the premise of not trusting it — a size cap (a 5 MB bounded reader), a MIME whitelist and .csv extension check, a row-count cap (20,000 rows), and filename sanitization. On output, the CSV is assembled with RFC 4180–compliant quoting, correctly escaping newlines, commas, and quotes.
export const escapeCsvField = (value: string): string => {
if (/["\n\r,]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
11. The Underpinnings to Survive Production
Finally, the operational side, built with the same priority as features.
- Periodic batches are 9 Vercel Crons. 8 on the learner-app side (point expiry, expiry reminder, cancellation-retention detection, renewal notice, paused monthly report, win-back, annual promo, bank-transfer status sync), and 1 on the admin side (monthly commission close
0 16 1 * *). All are made idempotent as described, so re-execution is safe. - Encrypted backups and DR. A GitHub Actions job runs
pg_dump(custom format) nightly → encrypts with age → stores in Cloudflare R2 (daily/andmonthly/). Furthermore, it prepares a recovery-drill workflow and a quarterly-DR-test reminder, preventing "we take backups but can't restore." Binaries are pinned by SHA-256, hardening the supply chain too. - Tests are 433 (Vitest). Learner app 247, admin 81, shared packages 105. It thickly covers pure logic like the state machine, pricing resolution, commission calculation, and CSV consistency.
- CI gate. Per PR, it starts Postgres and runs
typecheck → build → test → lint → format:check. If any of types, tests, or formatting fails, you can't merge.
Summary
Behind the appearance of a "learning platform," the real difficulty lay in keeping the billing domain in a form that a multi-person team can change fast and safely. The consistent design judgments for that can be summarized as follows.
- Push complexity into pure functions. Made pricing resolution, NFT perks, the bank-transfer state machine, and commission calculation side-effect-free, so they can be exhaustively tested without a DB.
- Idempotency by mechanism. The Stripe Webhook is a unique constraint + ordering guarantee + PII redaction, dunning is a sent-set, and commissions are an idempotency-keyed ledger. Resends, order reversals, and double-counting are structurally excluded.
- Money as integers. With
BigIntbasis-point arithmetic and minimum-amount carry-over, zero rounding error and re-execution safety. - Authentication down to the details. Constant-time comparison with a dummy hash, a response-time floor,
tokenVersionbulk revocation, and OTP's HMAC and lockout. - Harden the domain with types. Ban
as/any/enum, turn missed branches into compile errors withNeverError. Verify boundaries with Zod. - Operations on the same footing. 9 idempotent Crons, age-encrypted backups + recovery drills, 433 tests, and a CI gate per PR.
The difference between "building something that works" and "continuing to build a subscription foundation that survives production with multiple people" lies precisely in judgments like these, one by one. If you're considering new development or a rebuild involving subscription billing, payment reliability, or type-safe domain design, I'll take it on at this standard, from requirements definition through implementation and operation.