## 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>
89 lines
3.1 KiB
TypeScript
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')
|
|
})
|
|
})
|