feat(billing): enforce image generation quota with admin API and user display
Memind CI / Test, build, and release guards (push) Failing after 3s

Add period_images_bonus and ledger tracking, gate image_make on remaining quota,
expose admin image-quota routes, show remaining image quota in the balance popover,
and document that admin UI must live in memind_adm (5174) not ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-03 14:58:38 +08:00
parent 36d8e74784
commit 946d8756c8
14 changed files with 639 additions and 23 deletions
+54 -1
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
PLAN_CATALOG,
computeImageQuotaView,
createSubscriptionService,
ensurePlanCatalogSchema,
getPlanDef,
@@ -129,6 +130,7 @@ function makeSubRow(overrides = {}) {
period_tokens_used: 0,
period_images_limit: 50,
period_images_used: 0,
period_images_bonus: 0,
period_start: now,
period_end: now + 30 * 24 * 60 * 60 * 1000,
expires_at: now + 30 * 24 * 60 * 60 * 1000,
@@ -141,6 +143,31 @@ function makeSubRow(overrides = {}) {
};
}
describe('computeImageQuotaView', () => {
it('computes remaining quota with bonus', () => {
const view = computeImageQuotaView({
periodImagesLimit: 10,
periodImagesBonus: 5,
periodImagesUsed: 3,
planType: 'free',
});
assert.equal(view.total, 15);
assert.equal(view.remaining, 12);
assert.equal(view.unlimited, false);
});
it('treats limit=0 as unlimited', () => {
const view = computeImageQuotaView({
periodImagesLimit: 0,
periodImagesBonus: 0,
periodImagesUsed: 99,
planType: 'pro',
});
assert.equal(view.unlimited, true);
assert.equal(view.remaining, null);
});
});
describe('createSubscriptionService', () => {
describe('getActiveSubscription', () => {
it('returns null when no active subscription', async () => {
@@ -219,6 +246,31 @@ describe('createSubscriptionService', () => {
),
);
});
it('includes bonus in remaining capacity', async () => {
const subRow = makeSubRow({
period_images_limit: 1,
period_images_bonus: 2,
period_images_used: 2,
});
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const check = await svc.checkImageQuota('user-1', 1);
assert.equal(check.ok, true);
});
it('blocks when quota is exhausted', async () => {
const subRow = makeSubRow({
period_images_limit: 1,
period_images_bonus: 0,
period_images_used: 1,
});
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const check = await svc.checkImageQuota('user-1', 1);
assert.equal(check.ok, false);
assert.equal(check.code, 'image_quota_exceeded');
});
});
describe('cancelSubscription', () => {
@@ -294,7 +346,8 @@ describe('createSubscriptionService', () => {
const insert = conn.queries.find(({ sql }) => sql.includes('INSERT INTO h5_subscriptions'));
assert.ok(insert);
assert.equal(insert.params[3], 9_999_000);
assert.equal(insert.params[8], 0.45);
assert.equal(insert.params[5], 0);
assert.equal(insert.params[9], 0.45);
});
});
});