Skip to main content
友田 陽大

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 any or a missed switch branch 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 (build depends on ^build and typecheck) 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 FKs onDelete: Restrict to 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's enum in favor of union types + satisfies, with a missed switch turned into a compile error by NeverError(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, carrying tokenVersion in 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 resolvePricingClassification with 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 selectPrimaryNft to 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 returns purchasedAt is isolated in an explicit interim-override table.

  • [Idempotent, order-resilient Stripe webhooks] Re-sends are eliminated by an event-ID unique constraint, and event.created is compared against lastProcessedEventCreatedAt to 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 use BigInt basis-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.TransactionClient as the first argument, executing the business write and the record to AdminAuditLogs atomically in the same transaction. The server action owns the transaction boundary, revalidatePath, and authorization via requireAdmin(), 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. tokenVersion is 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 enforcing switch exhaustiveness at compile time with NeverError. 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 passes typecheck → 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.created ordering guarantee + PII redaction

  • Bank-transfer subscription: a pure state machine with grace/expiry/5-stage dunning (idempotent via a sent set)

  • Commissions: an idempotency-keyed append-only ledger + BigInt basis-point arithmetic + carry-over

  • Type-safety discipline: ban as/any/enum/non-null + NeverError exhaustiveness + 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

TypeScript 5
Next.js 16
React 19
React Server Components
Prisma 7
PostgreSQL
Mantine 8
Zod
React Hook Form
Stripe
jose (JWT)
bcryptjs
api.video
Vercel Blob
LINE Messaging API
pnpm
Turborepo
Vitest
Testing Library
Vercel
Vercel Cron
GitHub Actions
age (暗号化)
Cloudflare R2

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化できるか相談する

プロジェクト単位(請負)・技術顧問、どちらにも対応可能です

View all case studies