mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user