Files
moeka-project/server/apps/api/src/services/domain/flux.test.ts
T
37c837e502 refactor(api): extract payment CORE to support other payment providers [1/2] (#2335)
## Why

Stripe checkout used Stripe-only tables. This extracts a shared payment
CORE. New checkout writes `payment_order`. Old Stripe tables stay so
in-progress Sessions can still settle.

This PR is [1/2]. [#2368](https://github.com/moeru-ai/airi/pull/2368) is
[2/2]. That PR archives leftover Stripe tables after in-progress
Sessions finish or expire.

## Changes

- Add `payment_order` and `provider_account`.
- Copy `stripe_checkout_session` into `payment_order`.
- Copy `stripe_customer` into `provider_account`.
- Keep `stripe_*` tables and `user_flux.stripe_customer_id`.
- New checkout writes `payment_order` and stores
`metadata.payment_order_id`.
- Webhook resolves new Sessions by `metadata.payment_order_id`.
- Webhook resolves older Sessions by `provider_order_id`, then by a
leftover `stripe_checkout_session` row.
- That leftover-row lookup covers Sessions opened before this deploy,
and rows written while 0023 runs.
[#2368](https://github.com/moeru-ai/airi/pull/2368) deletes it after
those Sessions finish or expire.

## Test plan

- [x] `pnpm exec vitest run
server/apps/api/src/services/domain/payment/tests/payment.test.ts
server/apps/api/src/routes/stripe`
- [x] `pnpm -F @proj-airi/api-server typecheck`
- [x] `git diff --check`

## Visual changes

No user-visible changes.

## Open: settle after account deletion

Account deletion stamps `payment_order.deletedAt`. A Checkout Session
that is still open can still pay after that stamp. This PR keeps `main`
behavior. `settle` loads the row by id, including a soft-deleted row,
then marks it `paid` and credits Flux.

Follow-up policy: if `deletedAt` is set, skip. Do not update the archive
row. Do not credit Flux. Do not insert a live `provider_account`. Stripe
remains the payment record. Skip matches the soft-delete rule: a deleted
row is gone, not write it again.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: RainbowBird <git@luoling.moe>
2026-09-12 17:43:10 +08:00

89 lines
3.1 KiB
TypeScript

import type { Database } from '../../libs/db'
import type { createConfigKVService } from '../adapters/config-kv'
import { eq } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createTestRedis } from '../../libs/tests/redis'
import { userFluxRedisKey } from '../../utils/redis-keys'
import { createFluxService } from './flux'
import * as schema from '../../schemas'
function createMockConfigKV(overrides: Record<string, number> = {}): ReturnType<typeof createConfigKVService> {
const defaults: Record<string, number> = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides }
return {
get: vi.fn(async (key: string) => defaults[key]),
getOrThrow: vi.fn(async (key: string) => defaults[key]),
getOptional: vi.fn(async (key: string) => defaults[key] ?? null),
set: vi.fn(),
} as any
}
describe('fluxService (DB-backed)', () => {
let db: Database
let redis: ReturnType<typeof createTestRedis>
let get: ReturnType<typeof vi.spyOn>
let set: ReturnType<typeof vi.spyOn>
let service: ReturnType<typeof createFluxService>
let testUser: any
beforeAll(async () => {
db = await mockDB(schema)
const [user] = await db.insert(schema.user).values({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
beforeEach(async () => {
redis = createTestRedis()
get = vi.spyOn(redis, 'get')
set = vi.spyOn(redis, 'set')
service = createFluxService(db, redis, createMockConfigKV())
// Clean up flux-related tables
await db.delete(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, testUser.id))
})
it('getFlux should initialize new user with INITIAL_USER_FLUX and populate Redis', async () => {
const record = await service.getFlux(testUser.id)
expect(record.flux).toBe(100)
expect(set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '100')
})
it('getFlux should write a transaction entry on initialization', async () => {
await service.getFlux(testUser.id)
const txRecords = await db.select().from(schema.fluxTransaction).where(eq(schema.fluxTransaction.userId, testUser.id))
expect(txRecords).toHaveLength(1)
expect(txRecords[0]).toMatchObject({
type: 'initial',
amount: 100,
balanceBefore: 0,
balanceAfter: 100,
})
})
it('getFlux should return cached value from Redis on subsequent calls', async () => {
await service.getFlux(testUser.id)
await service.getFlux(testUser.id)
// Second call hits Redis cache
expect(get).toHaveBeenCalledTimes(2)
})
it('getFlux should load from DB when Redis cache misses', async () => {
// Pre-insert user flux directly
await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 42 })
const record = await service.getFlux(testUser.id)
expect(record.flux).toBe(42)
expect(set).toHaveBeenCalledWith(userFluxRedisKey(testUser.id), '42')
})
})