feat(billing): enforce image generation quota with admin API and user display
Memind CI / Test, build, and release guards (push) Failing after 3s
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user