A subscription learning platform for financial literacy (built multi-channel billing, idempotent payments, and agent commissions in a Next.js 16 monorepo)
A pnpm + Turborepo monorepo (learner app / operations admin / 14 shared packages) | as a core full-stack engineer, led team development across deterministically resolving pricing from 6 ID/NFT-benefit sources, Stripe webhook idempotency/ordering/PII redaction, a bank-transfer-subscription state machine, an append-only agent-commission ledger, and type-safety discipline (banning as/any/enum + NeverError)
Client
A financial-literacy subscription learning platform (not a place to solicit investment or trading, but to systematically teach "learning about money") | Form: a Web app for learners + an admin dashboard for operators (2 apps + 14 shared packages in a monorepo) | Channels: multi-channel billing where direct sales, agents (affiliates), NFT-holder benefits, and migration cohorts from an external platform intersect | Setup: team development by several people (GitHub). As one of the core full-stack engineers — holding the single largest share of commits in the repo — I designed and implemented across major areas: the learner frontend, operations admin, subscriptions/payments, agent commissions, DB schema, auth, and shared UI.
My role
One of the core full-stack engineers on a multi-person team. Designed and implemented across major areas — the learner frontend (Next.js 16 / React 19 / RSC), the operations admin dashboard, the subscription/payment domain (shared package `@workspace/subscription`), agent commissions (`@workspace/commission`), the DB schema (Prisma), auth (`@workspace/auth`), and shared UI — and owned the corresponding unit tests. Was especially deeply involved in pricing resolution, idempotent payments, and establishing the type-safety discipline.
Challenge (Situation & Task)
Behind the appearance of a "learning platform," the essential difficulty was the complexity of the billing domain. Users on direct sales, agents, NFT-holder benefits, and migration from an external platform mix, each with different pricing, benefits, and payment methods (Stripe / bank transfer). Handling this on one foundation without breaking — structurally preventing double charges, ordering reversal, PII leakage, and double-counted commissions — while imposing a type-safety discipline that doesn't regress even as a multi-person team keeps changing it fast, were the requirements.
The difficulties concentrated in the subscription learning platform were five.
-
Multi-channel × multi-dimensional pricing: the same product is used by direct sales, agents, students, an Alpha cohort, NFT holders (12 old/new tiers), and externally migrated users — at different prices and free-benefit periods. Scattering pricing branches across UI or API guarantees omissions and inconsistencies.
-
Idempotent, order-resilient payments: Stripe webhooks arrive "at least once" and "out of order." Double processing from re-sends, subscription-state rollback from late-arriving old events, and PII contained in the raw payload — these three had to be prevented at once.
-
Payment paths not on Stripe: recurring bank-transfer billing had to correctly transition payment deadlines, grace periods, dunning, and auto-expiry by itself, outside Stripe.
-
Agent-commission accuracy: a ledger was needed to compute rewards against revenue in a monthly batch that is at-least-once safe (safe to re-run), without rounding error, carrying amounts below the minimum payout to the next month.
-
Change fast and safely as a team: because multiple people develop in parallel, a discipline that locks the complex domain (pricing, benefits, state transitions) with types and tests, so that
anyor a missedswitchbranch never reaches production, was indispensable.
Why these technologies (Rationale)
Next.js 16 / React 19 (RSC) + a pnpm + Turborepo monorepo: because the learner app, operations admin, and 14 shared packages share the same domain vocabulary, domain logic is consolidated in
@workspace/*as the single source of truth.turbo's task graph (builddepends on^buildandtypecheck) guarantees build order and incremental runs.Prisma 7 + PostgreSQL (Supabase/RLS not adopted): a 72-model, 2,130-line schema is versioned across 93 generations with Prisma Migrate. Authorization is placed in the app layer (
requireAdmin()at the server-action boundary + use cases) rather than RLS, with audit-log FKsonDelete: Restrictto prevent tampering from the DB side too.Isolate the domain in pure functions: pricing resolution, NFT-benefit selection, bank-transfer state transitions, and commission calculation are implemented as side-effect-free pure functions — unit-testable as golden tests without a DB, with complex branches exhaustively type-checked.
Type-safety discipline (fully banning as/any/enum/non-null +
NeverError): avoiding even TypeScript'senumin favor of union types +satisfies, with a missedswitchturned into a compile error byNeverError(value: never). Boundaries are validated with Zod.Stripe + a self-built bank-transfer pipeline: card payments lean on Stripe, while bank transfer — which Stripe doesn't cover — runs on a self-built pure state machine with a grace period (7 days), expiry (30 days), and 5-stage dunning. Idempotency is ensured by a "sent-reminder set."
Auth is two-step:
jose(JWT) + bcrypt + OTP: sessions are HS256 JWTs in a__Host--prefixed cookie, carryingtokenVersionin the claims to bulk-revoke all tokens on a password change. OTP is HMAC-SHA256, 5-minute TTL, 5-attempt lockout. Account enumeration is suppressed with constant-time comparison of a dummy hash + a response-time floor.
What I did (Action)
[Deterministic resolution of multi-channel pricing] Six input streams — bank-transfer ID, monthly/annual payment cohort, student list, Alpha, old Pro late, and NFT benefits (12 tiers) — are folded into one pure function
resolvePricingClassificationwith a fixed priority order, deterministically mapping to a payment flow (Stripe Checkout / external migration / bank-transfer skip), a price bucket (K01/K02/K13), and the purchasable plans. Structurally eliminates scattered pricing logic and missed branches.[Stable resolution of NFT benefits] Implemented
selectPrimaryNftto pick "the benefit with the longest free period" when holding multiple, deterministically tie-breaking by free period → purchase date → tier. Old Pro is refined into EARLY/LATE at the purchase-date boundary, and the bridge until external-NFT holding judgment returnspurchasedAtis isolated in an explicit interim-override table.[Idempotent, order-resilient Stripe webhooks] Re-sends are eliminated by an event-ID unique constraint, and
event.createdis compared againstlastProcessedEventCreatedAtto skip late-arriving old events (preventing subscription-state rollback). The raw payload has PII keys (card/email/ip, etc.) recursively redacted to<redacted>before storage, retaining no PII.[Bank-transfer subscription state machine] Days elapsed since the payment deadline are folded into a pure function
computeNextStatus(ACTIVE → GRACE_PERIOD (7 days) → SUSPENDED (30 days) → CANCELED), with another pure function selecting T-30/14/7/0 5-stage dunning. Dunning is made idempotent by a containment check against a "sent set," so a daily Cron can re-run safely.[Append-only agent-commission ledger] Rewards are recorded in an append-only ledger keyed idempotently by
${scope}:${period}:${currency}:r${revision}, and monthly recalculation increments the revision to re-run safely. Amounts avoid floats and useBigIntbasis-point arithmetic (10000 = 100%) to eliminate rounding error, carrying amounts below the minimum payout to the next month.[Atomic audit logs] Admin-operation use cases take a
Prisma.TransactionClientas the first argument, executing the business write and the record toAdminAuditLogsatomically in the same transaction. The server action owns the transaction boundary,revalidatePath, and authorization viarequireAdmin(), separating concerns.[Robust auth details] Sign-in always runs a dummy-hash bcrypt comparison even for unknown users to make it constant-time, and password-reset link requests use a response-time floor to suppress account enumeration.
tokenVersionis carried in the JWT to bulk-revoke all sessions on a password change. OTP is stored as HMAC-SHA256 and locks out after 5 attempts.[Type-safety discipline and tests] Banning
as/any/enum/non-null and enforcingswitchexhaustiveness at compile time withNeverError. Ran 433 Vitest tests in CI centered on pure logic — pricing resolution, the state machine, commission calculation, CSV consistency, etc. (a gate that spins up Postgres per PR and passestypecheck → build → test → lint → format).
The design philosophy running through this product was to guarantee the "correctness" of billing structurally with pure functions, types, and DB constraints — not with operational carefulness.
Isolating complexity in pure functions:
Scattering ifs across UI or API handlers for multi-channel pricing inevitably breaks down. So six judgment inputs (bank transfer, annual/monthly cohort, student, Alpha, old Pro late, NFT benefits) are consolidated into one pure function with a fixed priority order, deterministically resolving to a payment flow, price bucket, and purchasable plans. With the same philosophy, NFT-benefit selection, bank-transfer state transitions, and commission calculation are all side-effect-free pure functions, exhaustively unit-testable without a DB.
Ensuring idempotency by construction:
Stripe webhooks absorb re-sends via an event-ID unique constraint, reject ordering reversal via event.created comparison, and recursively redact PII before storage. Dunning uses a containment check against a "sent set," and commissions use an idempotency-keyed append-only ledger — each structured so re-running causes no double processing. Payment amounts and rewards are handled in BigInt / integer minor units, eliminating accumulated rounding error.
Changing fast and safely as a team:
Because multiple people develop in parallel, banning as/any/enum/non-null and using union types + satisfies + NeverError creates a state where "a missed branch fails compilation." Boundaries are validated with Zod, and admin operations leave audit logs in the same transaction as the business write. A CI gate that stands up Postgres per PR and passes types, build, tests, lint, and format — plus daily encrypted backups (pg_dump → age encryption → Cloudflare R2) and a recovery-drill workflow — builds in production operations and DR.
Key technical decisions
Pure function
resolvePricingClassification: deterministically resolve pricing from 6 input streams (eliminating missed branches)Stripe webhooks: idempotent via event-ID unique constraint +
event.createdordering guarantee + PII redactionBank-transfer subscription: a pure state machine with grace/expiry/5-stage dunning (idempotent via a sent set)
Commissions: an idempotency-keyed append-only ledger +
BigIntbasis-point arithmetic + carry-overType-safety discipline: ban as/any/enum/non-null +
NeverErrorexhaustiveness + Zod boundary validation
Responsibilities
- Learner frontend (Next.js 16 / React 19 / RSC / Mantine)
- Operations admin dashboard (server actions + atomic audit logs)
- Subscription/payment domain (`@workspace/subscription` · pricing resolution · state machine)
- Agent commissions (`@workspace/commission` · append-only ledger)
- DB schema design (Prisma / PostgreSQL) and auth (`@workspace/auth`)
- Establishing the type-safety discipline and unit tests (Vitest)
Technologies
Results in numbers
- Automated tests
- 433testsVitest (learner app 247, admin 81, shared packages 105). Covers the state machine, pricing resolution, commission calculation, CSV consistency, and other pure logic.
- DB migrations
- 93generationsVersioned with Prisma Migrate. Evolved a 72-model, 43-enum, 2,130-line schema.
- Shared packages
- 14packages3 apps (learner/admin/scaffold) in a pnpm + Turborepo monorepo, with domain vocabulary as the single source of truth.
- Pricing-judgment input streams
- 6streamsBank transfer, annual/monthly cohort, student, Alpha, old Pro late, NFT benefits (12 tiers) — deterministically resolved to a price bucket.
- Scheduled batches (Cron)
- 9jobsMP expiry, dunning, churn recovery, annual promo, monthly commission close, etc. automated with Vercel Cron.
- Prisma models
- 72modelsLearning (course/stage/step), billing, agents, NFT benefits, and audit domains modeled relationally.
Results
- Consolidated multi-channel billing (direct sales, agents, NFT benefits, external migration) into a pure function that deterministically resolves to a price bucket from 6 ID/benefit judgments, eliminating scattered pricing logic and missed branches
- Made Stripe webhooks idempotent with "event-ID unique constraint + `event.created` ordering guarantee + PII redaction," structurally eliminating re-sends, ordering reversal, and PII retention
- Auto-operated bank-transfer subscriptions (non-Stripe) with a pure state machine ACTIVE→GRACE(7d)→SUSPENDED(30d)→CANCELED + a 5-stage dunning ladder (idempotent via a sent set, safe to re-run on a daily Cron)
- Computed agent commissions at-least-once-safe and rounding-error-free with an idempotency-keyed append-only ledger + `BigInt` basis-point arithmetic (10000=100%) + minimum-payout carry-over, in a monthly batch
- Suppressed account enumeration with constant-time comparison of a bcrypt dummy hash + a response-time floor, bulk-revoked all JWTs on a password change via `tokenVersion`, and used HMAC-SHA256 OTP with 5-minute TTL and 5-attempt lockout
- Imposed on team development the discipline of locking the complex billing domain with types: fully banning `as`/`any`/`enum`/non-null with `NeverError` exhaustiveness + Zod boundary validation
- Admin-operation use cases take a `Prisma.TransactionClient` as the first argument, recording the business write and the audit log (`AdminAuditLogs` / FK `onDelete: Restrict`) atomically in the same transaction
- Ensured production operations and DR with 9 Vercel Cron jobs (MP expiry, dunning, churn recovery, annual promo, commission close, etc.) and daily age-encrypted backups (pg_dump → Cloudflare R2) + a recovery-drill workflow
- Covered the state machine, pricing resolution, commission calculation, CSV consistency, and other pure logic with 433 Vitest tests, preventing regressions with a quality gate that spins up Postgres per PR (typecheck → build → test → lint → format)
同様の課題、抱えていませんか?
あなたのビジネス課題も、最新の技術で解決できます。 まずは30分の無料技術相談から、状況をお聞かせください。
自社の課題もSaaS化できるか相談するプロジェクト単位(請負)・技術顧問、どちらにも対応可能です