fix(monetization): release the PricingSlot when a model is transferred

The slot's primary key is the entity alone while releasePricingSlot refuses on
an owner mismatch, so a row left behind by a transfer is both unreleasable and
un-insertable (recordPricingSlot skips duplicates). The recipient could then
re-price that version forever without it ever counting against their allowance.

Deleted rather than moved: ownerId records who spent an allowance, like
ModelVersionSale.userId beside it, so moving it would charge the recipient for a
pricing they never made — what #4309 rejected for PaidAccess in the other
direction. Every other way a slot is stranded expires at the month turn because
the entity is gone; a transferred entity outlives it.

Appended to the transaction rather than inserted: the return value reads the
result array by hardcoded index, so anywhere else silently reassigns three
counts to the wrong statements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
briant
2026-08-25 16:46:07 -06:00
parent 52afba6d28
commit 53c213764f
2 changed files with 42 additions and 0 deletions
@@ -243,6 +243,34 @@ describe('transferModelOwnership moves the PaidAccess owner', () => {
expect(statement.sql).toMatch(/"updatedAt"\s*=\s*NOW\(\)/);
});
it('DELETES the PricingSlot rows rather than moving them', async () => {
await transferModelOwnership({
modelIds: MODEL_IDS,
targetUserId: TARGET_USER_ID,
modUserId: MOD_USER_ID,
});
const [statement, ...extra] = statementsFor('PricingSlot');
expect(
statement,
'no "PricingSlot" statement in the transfer transaction — the slot strands on the previous owner, where it can never be released and blocks every future insert for that version'
).toBeDefined();
expect(extra).toHaveLength(0);
// 🔴 DELETE, never UPDATE. Moving ownerId would charge the recipient a slot for a price they
// inherited rather than set — the thing #4309 rejected for PaidAccess in the other direction.
expect(statement.sql).toMatch(/DELETE\s+FROM\s+"PricingSlot"/);
expect(statement.sql).not.toMatch(/SET\s+"ownerId"/);
// Without the join predicate this drops EVERY slot on the site, and `USING "ModelVersion"` alone
// still matches — same reason as the gate statement above.
expect(statement.sql).toMatch(/USING\s+"ModelVersion"/);
expect(statement.sql).toMatch(/ps\."entityId"\s*=\s*mv\.id/);
expect(statement.sql).toMatch(/ps\."entityType"\s*=\s*'ModelVersion'::"PaidAccessEntityType"/);
expect(valueAfter(statement, /"modelId"\s*=\s*ANY\($/)).toEqual(MODEL_IDS);
// No `ownerId <> target` guard, unlike the UPDATEs above: the row goes whoever holds it, because
// what makes it harmful is the primary key blocking a re-insert, not whose name is on it.
expect(statement.sql).not.toMatch(/"ownerId"/);
});
it('busts every owner-derived cache for the transferred versions', async () => {
await transferModelOwnership({
modelIds: MODEL_IDS,
+14
View File
@@ -4828,6 +4828,20 @@ export async function transferModelOwnership({
SET "userId" = ${targetUserId}
WHERE id = ANY(${affectedImageIds}::int[])
`,
// PricingSlot is DELETED rather than moved. Its ownerId is a record of who spent an allowance,
// like ModelVersionSale.userId above, so moving it would charge the recipient for a pricing they
// never made. But the primary key is the entity alone, so a row left behind is unreleasable
// (releasePricingSlot refuses on the owner mismatch) AND blocks any future insert
// (recordPricingSlot skips duplicates) — the recipient could then re-price that version forever
// without it ever counting against their allowance. Every other way a slot is stranded expires at
// the month turn because the entity is gone; a transferred entity outlives it.
dbWrite.$executeRaw`
DELETE FROM "PricingSlot" ps
USING "ModelVersion" mv
WHERE ps."entityType" = 'ModelVersion'::"PaidAccessEntityType"
AND ps."entityId" = mv.id
AND mv."modelId" = ANY(${modelIds}::int[])
`,
]);
const tracker = new Tracker();