読み込み中
豆知識
XP・レベル・実績・ストリーク・ジェムは、記帳以外の便利機能の枠(エンタイトルメント)とつながっています。仕訳・申告・書類保存などのコンプライアンス機能は、レベルでは止めません。
本文は英語のエンジニア向け仕様です。 対象読者: バックエンド実装者・レビュア。関連:
README.md·docs/JOURNAL_ENTRY_LOGIC.md
┌─────────────────────┐ XP awards ┌──────────────────────────┐
│ user actions │ ───────────────► │ gamification.service │
│ (記帳, 月次, etc) │ │ awardXP / recordLogin │
└─────────────────────┘ └──────────┬───────────────┘
│ on level-up
▼
┌──────────────────────────┐
│ applyLevelUpUnlocks │
│ - unlock free themes │
│ - invalidate ent cache │
│ - log LEVEL_UP event │
└──────────┬───────────────┘
│
▼
┌─────────────────────┐ resolveEntitlements ┌────────────────────────┐
│ any API request │ ──────────────────► │ entitlement.service │
│ via quota.middleware│ │ base + grants merged │
└─────────────────────┘ └──────────┬─────────────┘
│
▼
429 QUOTA_REACHED or pass-through
Defined in src/enums/gamification.enum.ts (XPEventType, XP_REWARDS).
| Event | XP | Daily/Monthly cap | Trigger |
|---|---|---|---|
journal_entry_create |
8 | 50 / day | Each posted journal entry |
monthly_journal_complete |
200 | 2 / month | Closing the month (月次決算) |
document_create |
12 | 30 / day | Issued invoice, receipt, quote, etc. |
bank_reconcile |
15 | 20 / day | Matched bank line ↔ JE |
login_streak |
5 | 1 / day | First login of the day |
achievement_earned |
varies | — | Tier-dependent (see § 4) |
level_up |
0 (signal) | — | Emitted after a level transition |
Awards run inside awardXP(userId, eventType, opts). Every award:
XP_REWARDS[eventType] and per-day/month caps.xp_events_log (audit trail).user_gamification_progress.current_xp / total_xp_earned.current_xp crosses a level threshold → call applyLevelUpUnlocks.recordLogin(userId, today):
prev = lastLoginDate
gap = days_between(prev, today)
if gap == 0: // already counted today
no-op
elif gap == 1: // perfect continuation
currentLoginStreak += 1
elif gap == 2 and freezesAvailable > 0: // saved by a freeze
freezesAvailable -= 1
currentLoginStreak += 1
audit: STREAK_FREEZE_CONSUMED
else: // streak broken
currentLoginStreak = 1
if currentLoginStreak in STREAK_GEM_REWARDS:
award gems + freezes per STREAK_GEM_REWARDS[streak]
STREAK_GEM_REWARDS (mirror in frontend gem-store labels):
| Streak | Gems | Freezes |
|---|---|---|
| 7 | 10 | 1 |
| 14 | 20 | 1 |
| 30 | 50 | 3 |
| 60 | 100 | 5 |
| 100 | 200 | 10 |
| 365 | 1000 | 30 |
longestLoginStreak is updated whenever currentLoginStreak > longestLoginStreak.
Each achievement has up to four tiers (bronze / silver / gold / platinum). The XP award scales:
| Tier | XP |
|---|---|
| bronze | 25 |
| silver | 75 |
| gold | 200 |
| platinum | 500 |
When a tier is unlocked, awardXP(..., { eventType: ACHIEVEMENT_EARNED, amountOverride: tierXP }) is called and a user_achievements row is upserted with earnedAt.
applyLevelUpUnlocks(userId, oldLevel, newLevel):
THEMES for unlockAtLevel <= newLevel && unlockAtLevel > oldLevel && gemPrice == null — these are the "free with leveling" themes.user_profiles.unlocked_theme_ids (no duplicates).entitlement.service.cache.LEVEL_UP row into xp_events_log with metadata = { oldLevel, newLevel, unlockedThemeIds } so the frontend can render the celebration modal.The FE Zustand store (gamification-store.ts) detects the level-up via a polling refresh and sets showLevelUpModal = true.
// src/services/entitlement.service.ts
resolveEntitlements(userId: string): Promise<{
level: number
base: Entitlements // pure LEVEL_ENTITLEMENTS[level]
effective: Entitlements // base + grants merged (see § 7)
grants: ActiveGrant[]
// BOTH of these are populated together (or both null at Lv.30):
nextLevelEntitlements: Entitlements | null // full snapshot of level + 1
next: { level: number; delta: Partial<Entitlements> } | null
// keyed delta (changed keys only)
}>
getCurrentUsage(userId: string): Promise<UsageSnapshot>
incrementCounter(userId: string, key: QuotaKey, by: number): Promise<void>
adjustStorageBytes(userId: string, deltaBytes: number): Promise<void>
Parity guarantee (Apr 2026): nextLevelEntitlements and next are always
populated as a pair, or both null for users at the maximum level (Lv.30).
The pairing is enforced by entitlement.service.test.ts. The frontend
ALSO defends in depth via resolveNextEntitlement() and diffEntitlements()
in src/features/entitlements/helpers.ts, which use a three-layer fallback
(nextLevelEntitlements → merge of effective + next.delta → null).
This is what fixed the v5.50 "全部 0" rendering bug in the 成長記録 modal.
userId.getCurrentUsage joins user_monthly_quota_usage (current YYYY-MM) with rolled-up storage_bytes_used.The 特典 tab of the 成長記録 modal (formerly スタンプラリー) renders a
visual, advisor-grade growth map composed of these components:
| File | Role |
|---|---|
src/features/entitlements/components/growth-record-panel.tsx |
Top-level container (replaces deleted benefits-tier-compare.tsx). |
src/features/entitlements/components/tier-hero-card.tsx |
Current 称号 + 6 perk chips + XP bar + watermark glyph. |
src/features/entitlements/components/tier-band-strip.tsx |
All 7 tier bands at a glance with current marker. |
src/features/entitlements/components/next-step-delta-card.tsx |
Diff-only "next level" card. Handles max-tier ribbon, intra-band "same set" copy, and missing-data placeholder — never zeros. |
src/features/entitlements/components/locked-perk-thumbnail.tsx |
Greyscale snippet + "Lv.X で解放" tag for boolean perks. |
src/features/entitlements/components/tier-detail-accordion.tsx |
On-demand 7-band perk map (scroll target for the strip). |
src/features/entitlements/data/tier-bands.ts |
Single source of truth for the 7 bands (新人 / 見習い / 若手 / 中堅 / 熟練 / 匠 / 伝説). |
src/features/entitlements/data/perk-catalog.ts |
Icon + label + formatter per Entitlements key. |
src/features/entitlements/data/level-perk-previews.ts |
Static Japanese highlight bullets per band, used by the accordion. |
user_entitlement_grants rows are merged into effective like this:
for grant in active_grants:
switch grant.grantType:
case extra_ai_tokens: effective.aiTokensPerMonth += grant.amount
case extra_storage_gb: effective.storageBytesMax += grant.amount * GB
case bonus_companies: effective.maxCompanies += grant.amount
case permanent_badge: effective.canUseCustomBadge = true
case early_access: effective.betaFeatureAccess = true
case cosmetic_theme: effective.unlockedThemeIds |= grant.metadata.themeIds
case gem_bonus: (handled separately as gem ledger top-up)
A grant is "active" iff expires_at IS NULL OR expires_at > now().
// src/middlewares/quota.middleware.ts
requireQuota(quotaKey: QuotaKey, mode: "OBSERVE" | "ENFORCE" = process.env.QUOTA_MODE)
Behaviour:
1. If req.path matches COMPLIANCE_EXEMPT_PATTERNS → next() immediately.
2. Resolve { effective, usage } for req.user.id (cached).
3. Read max = effective[quotaKey], used = usage[quotaKey].
4. If used + 1 > max:
- mode = OBSERVE → log warning + Sentry breadcrumb, next()
- mode = ENFORCE → respond 429 with QUOTA_REACHED shape (see § 9)
5. Otherwise next(); the route handler is responsible for calling
incrementCounter on success (so failed validations don't burn quota).
Compliance exempt path patterns (excerpt):
/api/journal-entries/** ← always allowed
/api/tax-filings/** ← always allowed
/api/companies/*/articles/** ← always allowed (定款 retention is law)
/api/files/storage-usage ← reading usage is always allowed
{
"code": "QUOTA_REACHED",
"quota": "documentsPerMonth",
"used": 30,
"max": 30,
"nextLevel": 10,
"message": "Monthly document quota reached for level 9",
"messageJa": "今月のドキュメント作成数の上限に達しました。次のレベルで上限が増えます。",
}
Frontend api-client-service.ts intercepts 429 + this code, dispatches quota-reached-open, and the global <QuotaReachedDialog> renders three paths: Level Up / Use Gems / Contact Support.
spendGems({ userId, sku, themeId? }):
begin tx:
profile = lock(user_gamification_progress for update)
if profile.gem_balance < SKU_PRICE[sku]: throw INSUFFICIENT_GEMS
profile.gem_balance -= SKU_PRICE[sku]
profile.total_gems_spent += SKU_PRICE[sku]
switch sku:
streak_freeze: profile.streak_freezes_available += 1
ai_token_topup: create grant(extra_ai_tokens, 20000, expires=eom)
storage_topup: create grant(extra_storage_gb, 0.5, expires=now+30d)
extra_badge_slot: profile.extra_badge_slots += 1
cosmetic_theme: require themeId in THEMES with gemPrice ≠ null
append themeId to user_profiles.unlocked_theme_ids
invalidate ent cache(userId)
log GEM_SPEND in xp_events_log
commit
All paths are transactional + invalidation-aware so the FE sees the balance update on the next entitlements refresh.
file-upload.service.ts is the single source of truth for user_profiles.storage_bytes_used:
uploadFile(...): after successful upload, transactionally adjustStorageBytes(+bytes).deleteFile(...): after successful delete, transactionally adjustStorageBytes(-bytes).deleteFiles(...): batch delete, single adjustStorageBytes(-sumBytes).A nightly cron reconcile-storage.job.ts heals drift:
UPDATE user_profiles up
SET storage_bytes_used = COALESCE((
SELECT SUM(byte_size) FROM file_uploads f
WHERE f.user_id = up.user_id AND f.deleted_at IS NULL
), 0)
WHERE user_id = $1
Every meaningful event lands in two places:
xp_events_log — append-only audit log (user_id, event_type, xp_delta, metadata jsonb, occurred_at).quota.middleware and entitlement.service add breadcrumbs for level-up, quota-near, quota-hit, grant-create.For LLM features (AI advisor / chat), all calls are traced via Langfuse and tagged with userId + aiTokensThisMonth so we can correlate token-quota incidents with conversation quality.
OBSERVE mode for 2 weeks. No 429s emitted; only logging + Sentry.user_monthly_quota_usage and xp_events_log. Identify P95 / P99 outliers.extra_ai_tokens / extra_storage_gb grant that absorbs 95th percentile usage so the flip to ENFORCE feels invisible to honest users.QUOTA_MODE=ENFORCE. Monitor 429 rate hourly for the first 72 hours.Operational runbook: the concrete commands, SQL queries, Sentry alerts, and rollback procedure for each phase live in
docs/runbook-quota-rollout.md. Backend on-call owns it; product + compliance sign off on the Phase B → C transition.
読み込み中
豆知識