Skip to content
Merged

Shop #111

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ jobs:
bun scripts/test-api-token-security.ts
bun scripts/test-oauth.ts
bun scripts/test-money-api.ts
bun scripts/test-money-shop.ts
bun scripts/test-api-badges.ts
bun scripts/test-scheduled-threads.ts
bun scripts/test-scheduled-quota.ts
Expand Down
64 changes: 56 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,14 @@ copy during setup, and it stays on that machine.
that isn't imported within a day is deleted. By default only accounts a
week old can import, and staff approve each import first (Admin > Settings,
Import from X)
- X Money (Money in the side bar): every account has a wallet and sends money
to other accounts here, with a note. It is the instance's own play money,
- Equicoins (in the side bar): every account has a wallet and sends money
to other accounts, here or on a peer instance, with a note. It is play money,
not real currency: a wallet starts with `money.starting_cents` (default
$5), one payment moves up to `money.max_transfer_cents` (default $1,000),
each like on your posts earns you `money.like_cents` (default 50¢, once per
post and account). Admin > Money sets those three, shows the money in
post and account). Admin > Equicoins sets those three, shows the money in
circulation, the biggest balances and every payment, and has staff adjust a
balance or reverse a payment (Staff permissions: X Money; all recorded in
balance or reverse a payment (Staff permissions: Equicoins; all recorded in
the audit log).
- Requests: ask one account for an amount (it gets a notification to pay or
decline), or anyone, as a "Pay @you" card in a post that anyone who sees it
Expand All @@ -116,19 +116,67 @@ copy during setup, and it stays on that machine.
with a Pay button, or a purchase ("$3.52 at Taco Bell"). The composer's $
button adds a Pay button or one of your payments; "Share it in a post" does
it when sending or requesting
- Merchant mode (Settings > Your account > X Money), for gold organisations:
- Merchant mode (Settings > Your account > Equicoins), for gold organisations:
payments to them read as purchases under the name they choose, with their
banner if they like
- Payments show checkmarks and an affiliate's organisation badge; money
received, requests and declined requests come as notifications and pushes
- A payments feed (Admin > Money, off until an account is named): that
- A payments feed (Admin > Equicoins, off until an account is named): that
account posts every payment as "Alice sent $5 to Liza! #Money" (names, so nobody is mentioned) over its
card, without the note; reversing a payment takes its post down, and the
Send screen says payments are public
- The first time Money opens, each account accepts a disclaimer: the money
- Across instances: a wallet lives on its account's home instance, and a
payment to `name@peer` moves the money there once (signed, retried until
the peer answers, refunded if it refuses). Likes from peers' accounts pay
too. Requests, balance adjustments and reversals stay on one instance.
Admin > Equicoins settings:
- Use the network's economy: the starting balance, like reward and largest
payment come from xitter.space instead of this instance's own
(`money.follow_network`, off)
- Most taken in from one instance a day, its payments and likes together;
over it, payments are refused and refunded and likes stop paying
(`money.peer_daily_cents`, default $1,000, 0 for no limit). A peer's
word is all there is for what it sends, so this is the only brake on a
peer minting money here
- An account moving to another instance takes its balance along
(`money.move_balance`, on); the new instance takes it only for an account
it imported from there, once per move and up to 100 times its largest
payment
- A payment a peer hasn't taken after three days fails and goes back to
the sender
- Share this instance's totals with peers and show the network's in
circulation, wallets and payments (`money.network_totals`, off)
- The shop (`/account/money/shop`, opened in Admin > Equicoins): accounts
spend their balance on listings staff make. An admin's listing goes up at
once; one from a moderator waits for an admin. A purchase is charged at
once and waits for staff, who deliver it or refund it; an invite code is
the only thing that comes without review. Moderators deliver badges
(Staff permissions: Equicoins shop); everything else below is delivered
by an admin. A paid announcement stays up until an admin takes it down
in Admin > Equicoins. What a listing can give:
- A custom badge staff made, or a badge the buyer designs (an image up to
256 KB, GIFs animate)
- An instance announcement the buyer writes
- A blue checkmark for the listing's days (default 30), with longer posts
while it lasts; it comes off by itself, and one staff gave is never
touched
- An invite code, good for one sign-up within 30 days (not while sign-up
is open)
- A hashflag: the buyer's emoji after a hashtag, for its days (default 7)
- A sticker pack of up to 6 of the buyer's stickers, in everyone's composer
- An Explore card (title, summary, a search it opens, a picture) for its
days (default 1)
- A post boost: one of the buyer's posts shows once on each page of For
you, marked as an ad, for its days (default 1)
- Badges, custom badges and checkmarks can be gifts to another account
here, which gets a notification once staff deliver it
- Approved listings are shared with peer instances, where they stay hidden
until that instance's staff approve them too; buying across instances
isn't available yet
- The first time Equicoins opens, each account accepts a disclaimer: the money
is virtual, has no real-world value and can't be exchanged
- Off by default: Admin > Settings, feature switches turns it on. Switched
off, the Money tab, its pages, the composer's $ button, Pay buttons, like
off, the Equicoins tab, its pages, the composer's $ button, Pay buttons, like
earnings and its notifications go away, but cards already in posts still
show. `bun scripts/test-money.ts` checks it
- Polls with a photo on each choice
Expand Down
4 changes: 2 additions & 2 deletions scripts/test-money-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// X Money through the public API (/api/v1/money), against a migrated,
// Equicoins through the public API (/api/v1/money), against a migrated,
// disposable local database: the switch and the disclaimer, paying (once per
// idempotency key, never past the balance), requests of one account and
// "Pay @you" cards, who sees which payment, blocks, and earnings from likes.
Expand Down Expand Up @@ -114,7 +114,7 @@ try {
const earned = (await call(tok.a, "GET", "/money/activity")).earnings;
assert.deepEqual([earned[0].likes, earned[0].cents], [1, settings().money.like_cents]);

console.log("X Money API checks passed");
console.log("Equicoins API checks passed");
} finally {
await setSetting("features", features);
await sql`delete from users where id = any(${sql.array(Object.values(people), "INT8")})`;
Expand Down
131 changes: 131 additions & 0 deletions scripts/test-money-shop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// The shop (money-shop.ts, money-admin.ts) against a migrated, disposable
// local database: who may list and deliver what, the money held and refunded
// around a request, gifts, the paid announcement and taking it down, and the
// federation outbox giving up on a peer that never answers.
import { strict as assert } from "node:assert";
import { AdminError } from "../src/admin";
import { sql } from "../src/db";
import { newId } from "../src/ids";
import { moneyActions } from "../src/money-admin";
import { pumpOnce } from "../src/money-federation";
import { ShopError, buyShopItem, shopItems } from "../src/money-shop";
import { balanceOf } from "../src/money";
import { loadSettings, setSetting, settings } from "../src/settings";

const database = new URL(process.env.DATABASE_URL ?? "postgres://localhost/xitter");
assert(["localhost", "127.0.0.1"].includes(database.hostname) && /test|codex/.test(database.pathname), "Use a disposable local test database.");

await loadSettings();
const before = { features: settings().features, money: settings().money, money_shop: settings().money_shop };
const tag = newId().slice(-8);
const people = { admin: newId(), mod: newId(), buyer: newId(), friend: newId() };
const handle = (k: keyof typeof people) => `xs${k[0]}${tag}`;
const actor = (k: keyof typeof people, role: string | null) => ({ id: people[k], role, screen_name: handle(k) }) as any;
const admin = actor("admin", "admin"), mod = actor("mod", "moderator");
const run = (action: string, who: any, body: Record<string, unknown> = {}) => moneyActions[action].run(who, body as any);
const refused = async (p: Promise<unknown>, type: any, status?: number) => {
try {
await p;
assert.fail("expected a refusal");
} catch (e) {
assert(e instanceof type, `expected ${type.name}, got ${e}`);
if (status) assert.equal((e as AdminError).status, status);
}
};
const badgeId = newId();
const items: string[] = [];

try {
for (const k of Object.keys(people) as (keyof typeof people)[]) await sql`insert into users(id, screen_name, name, password_hash, role) values(${people[k]}::bigint, ${handle(k)}, ${k}, 'unused', ${k === "admin" ? "admin" : k === "mod" ? "moderator" : null})`;
await sql`insert into custom_badges (id, name, image, mime) values (${badgeId}::bigint, ${`shop badge ${tag}`}, ${Buffer.from("not a real image")}, 'image/png')`;
await setSetting("features", { ...before.features, money: true });
await setSetting("money_shop", { enabled: true, announcement: null });

// a peer's word is all there is for what it sends: the daily cap is on by default
assert.equal(settings().money.peer_daily_cents, 100_000, "the per-peer daily cap defaults to one largest payment");

// listings: an admin's goes up at once, a moderator's waits for an admin
const listed = async (who: any, body: Record<string, unknown>) => {
const r = (await run("createShopItem", who, body)) as { id: string; approved: boolean };
items.push(r.id);
return r;
};
const byMod = await listed(mod, { name: "Mod badge", price: "2", kind: "badge", badgeId });
assert.equal(byMod.approved, false);
const badgeItem = await listed(admin, { name: "Badge", price: "2", kind: "badge", badgeId });
assert.equal(badgeItem.approved, true);
assert.equal((await shopItems(true)).some((i: any) => i.id === byMod.id), false, "a pending listing isn't for sale");
await run("reviewShopItem", admin, { id: byMod.id, approve: true });
assert.equal((await shopItems(true)).some((i: any) => i.id === byMod.id), true);
const announcementItem = await listed(admin, { name: "Say something", price: "1", kind: "announcement" });
const checkmarkItem = await listed(admin, { name: "Blue", price: "1", kind: "checkmark" });
const dearItem = await listed(admin, { name: "Dear", price: "100000", kind: "badge", badgeId });

// buying holds the money; a second request for the same item waits for the first
const start = await balanceOf(people.buyer);
const order = await buyShopItem(people.buyer, badgeItem.id, {});
assert.equal(await balanceOf(people.buyer), start - 200);
await refused(buyShopItem(people.buyer, badgeItem.id, {}), ShopError);
await refused(buyShopItem(people.buyer, dearItem.id, {}), ShopError);
assert.equal(await balanceOf(people.buyer), start - 200, "a refused request costs nothing");

// a moderator refunds and delivers badges
await run("reviewShopOrder", mod, { id: order.id, approve: false });
assert.equal(await balanceOf(people.buyer), start, "a rejection refunds");
await refused(run("reviewShopOrder", mod, { id: order.id, approve: true }), AdminError);
const again = await buyShopItem(people.buyer, badgeItem.id, {});
await run("reviewShopOrder", mod, { id: again.id, approve: true });
assert.equal((await sql`select 1 from user_badges where user_id = ${people.buyer}::bigint and badge_id = ${badgeId}::bigint`).length, 1, "approved: the badge is on");
assert.equal(await balanceOf(people.buyer), start - 200);
await refused(buyShopItem(people.buyer, badgeItem.id, {}), ShopError);

// a gift goes to the recipient; the buyer pays
const gift = await buyShopItem(people.buyer, badgeItem.id, { gift: handle("friend") });
await run("reviewShopOrder", mod, { id: gift.id, approve: true });
assert.equal((await sql`select 1 from user_badges where user_id = ${people.friend}::bigint and badge_id = ${badgeId}::bigint`).length, 1, "the friend has the gift");
assert.equal(await balanceOf(people.buyer), start - 400);
assert.equal(await balanceOf(people.friend), settings().money.starting_cents, "the recipient paid nothing");

// anything beyond a badge is an admin's to deliver; a moderator can still refund it
const blue = await buyShopItem(people.buyer, checkmarkItem.id, {});
await refused(run("reviewShopOrder", mod, { id: blue.id, approve: true }), AdminError, 403);
await run("reviewShopOrder", mod, { id: blue.id, approve: false });
assert.equal(await balanceOf(people.buyer), start - 400);
const said = await buyShopItem(people.buyer, announcementItem.id, { text: `hello from ${tag}`, link: "https://example.com/x" });
await refused(run("reviewShopOrder", mod, { id: said.id, approve: true }), AdminError, 403);
await run("reviewShopOrder", admin, { id: said.id, approve: true });
assert.equal(settings().money_shop.announcement?.text, `hello from ${tag}`);
assert.deepEqual(settings().money_shop.announcement?.buttons, [{ label: "Learn more", url: "https://example.com/x" }]);

// and comes down again
await run("clearShopAnnouncement", admin);
assert.equal(settings().money_shop.announcement, null);
await refused(run("clearShopAnnouncement", admin), AdminError);

// a transfer a peer never takes goes back to the sender after three days, a fresh one keeps trying
const stale = newId(), fresh = newId();
await sql`update money_wallets set balance_cents = 1000 where user_id = ${people.buyer}::bigint`;
const held = await balanceOf(people.buyer);
await sql`update money_wallets set balance_cents = balance_cents - 70 where user_id = ${people.buyer}::bigint`;
for (const [id, age] of [[stale, "4 days"], [fresh, "1 minute"]] as const) {
await sql`insert into money_transfers (id, sender_id, recipient_id, amount_cents, note, idem, federation_status)
values (${id}::bigint, ${people.buyer}::bigint, ${people.friend}::bigint, 35, '', ${`t-${id}`}, 'pending')`;
await sql`insert into money_federation_outbox (transfer_id, destination, sender_id, recipient_id, amount_cents, created_at)
values (${id}::bigint, 'peer.invalid', ${people.buyer}::bigint, 1, 35, now() - ${age}::interval)`;
}
await pumpOnce();
const status = async (id: string) => (await sql`select federation_status from money_transfers where id = ${id}::bigint`)[0].federation_status;
assert.equal(await status(stale), "failed", "the stale transfer gave up");
assert.equal(await status(fresh), "pending", "the fresh one is still trying");
assert.equal((await sql`select attempts from money_federation_outbox where transfer_id = ${fresh}::bigint`)[0]?.attempts, 1);
assert.equal(await balanceOf(people.buyer), held - 35, "only the stale one was refunded");

console.log("Shop checks passed");
} finally {
await setSetting("features", before.features);
await setSetting("money_shop", before.money_shop);
await sql`delete from users where id = any(${sql.array(Object.values(people), "INT8")})`;
if (items.length) await sql`delete from money_shop_items where id = any(${sql.array(items, "INT8")})`;
await sql`delete from custom_badges where id = ${badgeId}::bigint`;
await sql.close();
}
2 changes: 1 addition & 1 deletion scripts/test-money.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// End-to-end check of X Money (/account/money) against a running server:
// End-to-end check of Equicoins (/account/money) against a running server:
// starting balance, sending, the note, refusals, double-pressed Send, blocks,
// admins adjusting a balance and the side bar entry. Makes its own accounts and
// removes them.
Expand Down
6 changes: 3 additions & 3 deletions src/admin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { loadBuild } from "./build";
import { INSTANCE_ID, isPeer } from "./federation";
import { sql } from "./db";
import { newId } from "./ids";
import { MEDIA_DIR, sniffImage } from "./media";
import { BADGE_TYPES, MEDIA_DIR, sniffImage } from "./media";
import { NOTICE_TYPES } from "./model";
import { hateIn, hateInName } from "./hate";
import { forgetHeld } from "./held";
Expand Down Expand Up @@ -76,8 +76,8 @@ export const toolActions: Record<string, Action> = {
if (!encoded || encoded.length > 350_000 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) throw new AdminError("Upload an image up to 256 KB.");
const image = Buffer.from(encoded, "base64");
const info = sniffImage(image);
if (image.length > 256 * 1024 || !info || !["image/png", "image/jpeg", "image/webp"].includes(info.mime) || info.width > 1024 || info.height > 1024)
throw new AdminError("Use a PNG, JPEG or WebP up to 1024×1024 and 256 KB.");
if (image.length > 256 * 1024 || !info || !BADGE_TYPES.includes(info.mime) || info.width > 1024 || info.height > 1024)
throw new AdminError("Use a PNG, JPEG, WebP or GIF up to 1024×1024 and 256 KB.");
const id = newId();
await sql`insert into custom_badges (id, name, image, mime) values (${id}::bigint, ${name}, ${image}, ${info.mime})`;
await audit(a, "create_badge", id, { name });
Expand Down
Loading
Loading