メインコンテンツへスキップ
友田 陽大
決済・課金
B2B SaaS
アーキテクチャ設計
Next.js
TypeScript
Stripe
PostgreSQL
型安全性

サブスク学習プラットフォームのアーキテクチャ徹底解剖:マルチチャネル課金・冪等な決済・代理店コミッション・型安全規律

Next.js 16 × Prisma のモノレポで作られた金融リテラシー教育のサブスク基盤を、実コードを唯一の真実源として解剖します。6系統の入力から料金を決定的に解決する純粋関数、Stripe Webhookの冪等性・順序保証・PII墨消し、銀行振込サブスクの状態機械、代理店コミッションの追記専用台帳、tokenVersionとOTPの認証、NeverErrorによる網羅検査までを実装レベルで解説。

公開日
読了時間
20分
著者
友田 陽大
シェア

「学習プラットフォーム」と聞くと、動画を並べて進捗を記録するだけの単純なアプリを想像するかもしれません。ですが実際に本番運用に耐えるサブスクリプション製品を作ると、難所のほとんどは 課金ドメインの複雑さ に集中します。

題材は、金融リテラシー教育(投資や売買を勧める場ではなく「お金の学び」を提供する)のサブスクリプション学習プラットフォームです。受講者向け Web アプリと運営者向け管理ダッシュボードを、pnpm + Turborepo のモノレポ(3 アプリ・14 共有パッケージ)で構築し、直販・代理店・NFTホルダー優待・外部プラットフォームからの移行という複数チャネルの利用者が、異なる料金・特典・支払い手段で混在します。私は複数名のチーム開発の中核フルスタックエンジニアの一人として、フロント・管理画面・サブスク/決済・コミッション・DBスキーマ・認証を横断して実装しました。

この記事のルールはひとつ。唯一の真実源は実コードです。スライドの綺麗な図ではなく、実際に動いている TypeScript / Prisma から、エンタープライズが「この設計なら任せられる」と判断する材料になる箇所だけを抜き出して解説します。

数字の前提: 本文中の定量値(433 テスト、93 マイグレーション、72 モデル、14 パッケージ、9 本の Cron など)はすべてリポジトリから機械的にカウントした実測値です。会員数・売上・継続率といった事業 ROI はクライアントの実データが必要なため扱いません。捏造はしない、という方針です。


1. システム全体像:モノレポと信頼境界

まず構成です。apps/front(受講者)・apps/admin(運営)・apps/sample-app(scaffold)の 3 アプリと、@workspace/* の 14 パッケージで成り立っています。

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 / …

技術スタックは Next.js 16.1 / React 19 / TypeScript 5 / Prisma 7 / PostgreSQL / Mantine 8 / Zod / Stripe 17。ホスティングは Vercel(リージョン sin1)です。

設計上はっきりさせておくべき点として、このプロダクトは Supabase も RLS も使っていません。認可は DB の行レベルではなく、アプリ層——サーバーアクションの入口の requireAdmin() と、ユースケースに引き渡される actorUserId——で強制します。その代わり、改ざんされて困る監査ログは、後述するように DB の外部キー制約(onDelete: Restrict)でも守ります。「どこで信頼境界を引くか」を明示的に選んでいるのがポイントです。

turbo.json のタスクグラフは build^build(依存パッケージのビルド)と typecheck に依存し、集約ゲート ci-checktypecheck → build → test → lint → format:check を一気に通します。型が通らなければビルドできない順序を構造で強制しているわけです。


2. マルチチャネル料金を「1 つの純粋関数」に畳む

このプロダクトで設計上いちばん厄介なのが料金体系です。同じプロダクトを、

  • 銀行振込の利用者(Stripe を通さない)
  • 外部プラットフォーム由来の月額/年額コホート
  • 学生リスト・Alphaコホート
  • NFTホルダー(旧/新あわせて 12 ティアの優待)
  • 上記いずれでもない直販

が、それぞれ異なる料金・無料特典期間・購入可能プランで利用します。これを UI や API ハンドラに if で散らすと、分岐の取りこぼしが必ず事故になります。

そこで、料金の決定を 副作用のない 1 つの純粋関数に畳み込みました。packages/subscription/src/pricing-classification/resolve.tsresolvePricingClassification です。

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"], /* … */ };
};

設計のポイントは 3 つです。

  1. 入力は 6 系統の判定フラグ+NFT特典に正規化してから渡す。料金関数は「DB を引く」「セッションを見る」といった副作用を一切持たず、入力 → 出力が決定的です。だから DB なしで全分岐を単体テストできる
  2. 出力は生の Stripe Price ID ではなく、料金バケット(K01/K02/K13)+決済フロー+購入可能プラン。価格そのものは下流の 1 箇所(バケット → 価格バージョン → Stripe Price ID)で解決します。価格を直接返さないことで、「料金の判定」と「実際の課金額」の関心を分離しています。
  3. 優先順位が上から読める。銀行振込は最優先でスキップ、外部移行コホートは Stripe を通さない、という業務ルールがコードの並びそのものになっています。

flow のような 状態を表す値に TypeScript の enum を使わず、Union 型("STRIPE_CHECKOUT" | "BANK_TRANSFER_SKIP" | …)で表現しているのも意図的です。この選択が後述の網羅検査(NeverError)に効いてきます。


3. NFT 特典の解決:最長無料期間を決定的に選ぶ

NFTホルダーは複数の NFT を保有していることがあります。「どの特典を適用するか」を曖昧にすると、ユーザーごとに有利/不利がブレて不公平になります。そこで 「無料期間が最長の特典」を選び、同点はさらに決定的にタイブレークする純粋関数を用意しました。

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;
};

LIFETIME_FREE の特典は Number.POSITIVE_INFINITY 月として扱い、確実に最上位へ来るようにしています。タイブレークを「購入日 → ティア文字列」まで決め切ることで、入力が同じなら出力も必ず同じ——つまりテストで固定できる性質を担保しています。

レガシーな OLD_PRO ティアは、購入日の境界で EARLY / LATE に refine します。

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";
};

外部の NFT 保有判定 API がまだ purchasedAt を返さない、という現実の制約は、明示的に「暫定」と名付けたオーバーライドテーブルで橋渡ししています。「将来 API が直ったら消す」という意図がスキーマに残っているので、技術的負債が黙って恒久化しません。


4. 冪等で順序に強い Stripe Webhook

Stripe の Webhook には、本番品質を分ける 2 つの厄介な性質があります。「少なくとも 1 回」配信される(=同じイベントが複数回届く)、そして 順不同で届きうる(=古いイベントが新しいイベントの後に届く)。素朴に処理すると、二重処理やサブスク状態の巻き戻りが起きます。

第 1 層:イベント ID の一意制約で再送を吸収

model StripeWebhookEvents {
  id            String    @id @default(uuid())
  stripeEventId String    @unique   // ← 同じイベントは 2 行目を作れない
  eventType     String
  processedAt   DateTime?
  rawPayload    Json
  // …
}

第 2 層:event.created の比較で順序逆転を弾く

サブスク行に「最後に処理したイベントの作成時刻」を保持し、それより古いイベントが来たらスキップします。これが無いと、pause の Phase-1/Phase-2 のようなイベントが順不同で届いたときに、サブスクが意図せず古い状態へ巻き戻ってしまいます。

// 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})`,
  };
}

第 3 層:保存前に PII を再帰的に墨消し

Stripe の生ペイロードには、カード情報・メール・IP・氏名・住所などの PII が含まれます。デバッグのために保存はしたい、しかし PII は保持したくない。そこで 保存する前に、PII キーを再帰的に <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>";
};

ブラックリスト方式(隠すキーを列挙)で実装しつつ、ネストした構造を再帰的にたどるので、charges.data[].billing_details.address のような深い PII も墨消しされます。「保存はするが PII は残さない」を、ログ運用の注意ではなくコードで保証しているのが要点です。


5. Stripe に乗らない決済:銀行振込サブスクの状態機械

カード決済は Stripe に寄せられますが、銀行振込の継続課金は Stripe の外です。入金期限・猶予期間・督促・自動失効を自前で正しく遷移させなければなりません。ここを if の塊で書くと必ず破綻するので、経過日数 → ステータスの写像を純粋関数にしました。

// 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 日〜
};

督促メールの送信判定も別の純粋関数に切り出し、「送信済み集合」への包含チェックで冪等化しています。これにより、日次 Cron が同じ日に二度走っても、同じ督促を二重送信しません。

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;
};

「時刻」と「送信済みフラグ」を引数として外から渡す設計なので、任意の日付・任意の送信履歴を入力にしてテストできる。状態機械のような時間依存ロジックは、こうして純粋関数に閉じ込めるのが定石です。


6. 代理店コミッション:追記専用台帳と整数演算

代理店(アフィリエイト)への報酬計算は、お金の計算なので一切の妥協が許されません。要件は「月次バッチを at-least-once(再実行が安全)に」「丸め誤差なく」「最低支払い額未満は翌月へ繰り越す」でした。

冪等キー付きの追記専用台帳

台帳の各エントリは、scope・期間・通貨・リビジョンから決定される冪等キーで一意化されています。月次の再計算は revision を増やすだけで、同じバッチを何度流しても二重計上が起きません。

// 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 のベーシスポイント演算

報酬率は ベーシスポイント(10000 = 100%)の整数で持ち、計算は BigInt、保存は「マイナー単位(円なら 1 円)の整数」。Decimal の不安定さや float の丸め誤差を構造的に避けています。

// 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) };
};

「再実行が安全(冪等)」「丸め誤差ゼロ(整数演算)」「端数は繰り越す」——お金を扱うバッチに必要な 3 つの性質を、すべて型と純粋関数で表現しています。


7. 認証の細部:列挙対策・一括失効・OTP

アカウント列挙を抑止する定数時間サインイン

「このメールアドレスは登録されていますか?」を、応答の速さの差(タイミングオラクル)から推測されないようにします。ユーザーが存在しなくても必ずダミーハッシュと bcrypt 比較を実行し、処理時間を一定に保ちます。

// 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;
}

パスワード再設定リンクの要求のように「存在の有無で分岐しがちな」経路には、さらに 応答時間フロア(一定時間に満たなければ待つ)を噛ませて、時間差を均しています。

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); });
}

tokenVersion で全 JWT を一括失効

セッションはステートレスな JWT(jose / __Host- 接頭辞付き Cookie)です。ステートレスの弱点は「個別のトークンを即座に失効できない」ことですが、ここを tokenVersion(クレーム名 v で解決しています。ユーザーの世代番号を JWT に載せ、パスワード変更などのタイミングで DB 側の世代を進めると、古い v を持つトークンは一斉に無効になります。

// 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(), // ユーザー世代(強制ログアウト用途)
});

パスワード更新は、新しい tokenVersion を同じトランザクションで返す契約になっており、「パスワードを変えたら全端末からログアウトされる」という当然の期待を、ステートレス JWT のまま満たします。

OTP は HMAC-SHA256・5 分・5 回ロックアウト

二要素のワンタイムコードは、平文で保存しません。シークレット鍵付きの HMAC-SHA256 でハッシュ化して保存し、有効期限・試行回数・ロックアウトで総当たりを抑止します。

// 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");

5 回間違えると otpLockedUntil を 1 時間先に設定してロックします。コードは consumeOtp で使い捨て、リプレイも防ぎます。


8. 型でドメインを固める:NeverError による網羅検査

ここまで「Union 型で状態を表す」と繰り返してきました。その狙いは switch の分岐漏れをコンパイルエラーにすることです。鍵になるのが @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);
    }
  }
}

使い方はこうです。switch のすべてのケースを処理し終えると、default に来る値の型は never に絞り込まれます。もし新しい料金フローを Union に足して switch の更新を忘れると、その値は never ではなくなり、new NeverError(flow) がコンパイルエラーになります。

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);
  }
}

このプロジェクトでは as(型アサーション)・any!(non-null)・enum規約で禁止しています。enum を避けて as const satisfies の Union にし、網羅性は NeverError でコンパイル時に保証する——複雑な課金ドメインを複数人で触っても、「分岐の取りこぼし」が本番に出ない仕組みです。境界(API 入力・Webhook ペイロード・CSV 行)は Zod で検証し、内側は信頼できる型だけが流れます。


9. ユースケース層とアトミックな監査ログ

管理操作(代理店の停止など)は、「業務の書き込み」と「監査ログ」が必ずセットで成立しなければなりません。途中で落ちて片方だけ残ると、監査証跡が嘘になります。

そこでユースケースは Prisma.TransactionClient を第一引数で受け取り、自分ではトランザクションを開きません。業務書き込みと AdminAuditLogs への記録を、同じトランザクションでアトミックに行います。

// 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 },
  });
};

トランザクション境界・キャッシュ再検証・認可はサーバーアクション側が所有します。requireAdmin() を入口で必ず通し、actorUserId をユースケースへ引き渡します。

// 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}`);      // ← 再検証もアクションが所有
};

監査ログの不変性は、アプリ層だけでなく DB の制約でも守ります。AdminAuditLogs.actor の外部キーは onDelete: Restrict なので、監査証跡を残した管理者は物理削除できません。「アプリのバグで消えてしまった」が起きない設計です。


10. CSV 取り込みの冪等性と入力検証

運営は、年額会員・銀行振込・コホート判定用の ID リストを CSV でアップロードします。同じファイルを二度上げても事故が起きないよう、(アップロード者, SHA-256 チェックサム, リスト種別) で冪等化しています。

// 同一ファイルの再アップロードは 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 };
}

チェックサムはアップロード時に計算します。

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),
  };
};

外部からのアップロードなので、信頼しない前提で多層に防御します——サイズ上限(5 MB のバウンドリーダー)、MIME ホワイトリストと .csv 拡張子チェック、行数上限(2 万行)、ファイル名のサニタイズ。出力時には RFC 4180 準拠のクオートで CSV を組み立て、改行・カンマ・引用符を正しくエスケープします。

export const escapeCsvField = (value: string): string => {
  if (/["\n\r,]/.test(value)) {
    return `"${value.replace(/"/g, '""')}"`;
  }
  return value;
};

11. 本番運用に耐えるための足回り

最後に、機能と同じ優先度で作り込んだ運用面です。

  • 定期バッチは 9 本の Vercel Cron。受講者アプリ側に 8 本(ポイント失効・失効リマインド・解約リテンション検知・更新通知・休止月次レポート・ウィンバック・年次プロモ・銀行振込ステータス同期)、管理側に 1 本(コミッションの月次締め 0 16 1 * *)。いずれも前述のとおり冪等に作ってあるので、再実行が安全です。
  • 暗号化バックアップと DR。GitHub Actions が毎晩 pg_dump(custom 形式)→ age で暗号化Cloudflare R2 に保管します(daily/monthly/)。さらに復旧訓練ワークフロー四半期 DR テストのリマインダまで用意し、「バックアップは取っているが戻せない」を防ぎます。バイナリは SHA-256 で固定し、サプライチェーンも固めています。
  • テストは 433 件(Vitest)。受講者アプリ 247・管理 81・共有パッケージ 105。状態機械・料金解決・コミッション計算・CSV 整合のような 純粋ロジックを厚くカバーしています。
  • CI ゲート。PR 毎に Postgres を起動し、typecheck → build → test → lint → format:check を通します。型・テスト・整形のいずれかが落ちればマージできません。

まとめ

「学習プラットフォーム」という外見の裏で、本当の難しさは 課金ドメインを、複数人のチームが速く・安全に変え続けられる形に保つことにありました。そのための一貫した設計判断は、次のようにまとめられます。

  • 複雑さは純粋関数へ。料金解決・NFT特典・銀行振込の状態機械・コミッション計算を副作用なしにして、DB なしで網羅テストできるようにした。
  • 冪等性は仕組みで。Stripe Webhook は一意制約+順序保証+PII墨消し、督促は送信済み集合、コミッションは冪等キー付き台帳。再送・順序逆転・二重計上を構造的に排除。
  • お金は整数でBigInt のベーシスポイント演算と最低額の繰り越しで、丸め誤差ゼロ・再実行安全。
  • 認証は細部まで。ダミーハッシュの定数時間比較・応答時間フロア・tokenVersion 一括失効・OTP の HMAC とロックアウト。
  • 型でドメインを固めるas/any/enum を禁止し、NeverError で分岐漏れをコンパイルエラーに。Zod で境界を検証。
  • 運用も同列で。9 本の冪等な Cron、age 暗号化バックアップ+復旧訓練、433 テスト、PR 毎の CI ゲート。

「動くものを作る」と「複数人で本番運用に耐えるサブスク基盤を作り続ける」の差は、まさにこうした一つひとつの判断にあります。サブスクリプション課金・決済信頼性・型安全なドメイン設計を伴う新規開発や立て直しをご検討でしたら、要件定義から実装・運用まで、この水準でお引き受けします。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

金融リテラシー教育のサブスク学習プラットフォーム(マルチチャネル課金・冪等な決済・代理店コミッションをNext.js 16モノレポで構築)

ケーススタディを見る