openvibers/openvibe.vip Commits

VIP: account export and deletion (ADR-033) — a member's memberships, preferences, cache and checkouts deleted with paid periods kept; a creator suspended without their name, plan versions kept; subscriptions at boot (SDK v0.37.0) (#19)

OP OpenVibers committed 9 Oct 2026 · ef5c7c0b59 · 11 files, +214 −13

Changes

ddiff --git a/README.md b/README.md
iindex d70bb8e..554507a 100644
---- a/README.md
++++ b/README.md
@@@ -80,7 +80,8 @@ Called elsewhere, as the service principal `vip`:
| Service | Grant | Why |
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.subscription.manage`, `billing.entitlement.check` | checkout, credit-paid periods and cancel, the authoritative check |
--| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`scripts/subscribe.js`) | the outbox relay; the three subscriptions for Billing's events |
++| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` (`scripts/subscribe.js`, and at boot) | the outbox relay; the three subscriptions for Billing's events; the two account subscriptions (created at boot when missing) |
++| OpenVibe.Network | `network.account.export.contribute`, `network.account.deletion.confirm` (granted last, once this release is live) | account export and deletion (ADR-033) |
## Run it
@@@ -345,6 +346,21 @@ batches and debounces (one POST per 30 s window). A draft, an unchanged edit, a
network plans (no public creator page) never ping, and a failed ping never takes a publish down.
Unset: off — the key route is not mounted and nothing is sent (`test/indexnow.test.js`).
++## Account export and deletion (ADR-033)
++
++`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events` and are answered by
++`server/domain/account-data.js` (`openvibe-sdk/account-data`), outside the billing inbox, with one receipt per export
++and deletion in `account_data_events`.
++
++- **A member:** the export carries their memberships, preferences, entitlements, checkouts and paid periods. The
++ deletion removes the memberships, preferences, entitlement cache and checkout hand-offs. Paid periods are money
++ records and stay, counted as retained.
++- **A creator:** the export carries their profile, plans, plan versions and perks. Their creator row stays, because
++ plans, perks and other people's memberships reference it, but it is suspended and loses its username, display name
++ and bio. Plans, perks and gated rules lose `created_by`. Plan versions stay whole: they are the immutable terms
++ members joined under.
++- **Billing:** payments, refunds and subscriptions are Billing's records, and its own consumer answers for them.
++
## Before and after the Billing cutover
Billing runs in shadow mode until the Wave 8 cutover (Live stays authoritative for money,
ddiff --git a/STATUS.json b/STATUS.json
iindex 99b61b4..692bcad 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",
"contracts": "openvibe-contracts v0.112.0",
-- "sdk": "openvibe-sdk v0.35.0",
++ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"roadmap": "Wave 10",
ddiff --git a/migrations/0003_account_data.sql b/migrations/0003_account_data.sql
nnew file mode 100644
iindex 0000000..d5fdcb5
---- /dev/null
++++ b/migrations/0003_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.VIP: the receipts of the ADR-033 account export and deletion deliveries this service applied
++-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.
++-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
++
++CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome JSONB,
++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
++ sent_at TIMESTAMPTZ
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 913e450..745e986 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
},
@@@ -859,9 +859,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.35.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
-- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",
++ "version": "0.37.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
++ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 5045eab..b3e9eb4 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex 4aac15e..79780eb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -27,6 +27,8 @@ const { createKeyProvider, createUserAuth } = require('./network');
const { createBillingClient } = require('./billing-client');
const { createVipOutbox } = require('./events/outbox');
const { consumerRouter } = require('./events/consumer');
++const accountDataLib = require('./domain/account-data');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createDomain } = require('./domain');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
@@@ -104,7 +106,13 @@ async function createApp(opts = {}) {
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const consumer = consumerRouter({ domain, config, log });
++ // Account export and deletion (ADR-033, domain/account-data.js), pushed to Network's internal routes with VIP's own
++ // client-credentials token; a test injects a stand-in through opts.accountSend.
++ const accountData = accountDataLib.create({ db: domain.db, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const consumer = consumerRouter({ domain, config, log, accountData, accountSend });
app.use('/internal', consumer.router);
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// opts.limitsNow: the limiter's clock (tests).
@@@ -142,7 +150,7 @@ async function createApp(opts = {}) {
return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));
});
-- Object.assign(app.locals, { config, db, domain, keys, outbox, billing, consumer, metrics, indexnow });
++ Object.assign(app.locals, { config, db, domain, keys, outbox, billing, consumer, metrics, indexnow, accountData });
return app;
}
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
nnew file mode 100644
iindex 0000000..70d01a5
---- /dev/null
++++ b/server/domain/account-data.js
@@@ -0,0 +1,48 @@
++'use strict';
++
++/**
++ * Account export and deletion → VIP (ADR-033; openvibe-sdk/account-data). A person is here as a member or a creator.
++ *
++ * member their memberships, badge preferences, entitlement cache (Billing re-sends what is still true) and checkout
++ * hand-offs are deleted. Their paid periods (Billing transaction ids, start, end, reversal) are money
++ * records and stay, counted as retained: VIP's history must still reconcile with Billing's books.
++ * creator their creator row stays, because plans, perks and other people's memberships reference it, but it is
++ * suspended and loses its username, display name and bio; plans, perks and gated rules they made lose
++ * created_by. Plan versions stay whole: they are the immutable terms members joined under (a trigger keeps
++ * them so), counted as retained.
++ *
++ * Billing owns payments, balances, refunds and the subscriptions themselves; its own consumer answers for those.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const anonymize = { anonymize: {} };
++
++const TABLES = [
++ { table: 'vip_memberships', subject: 'member_subject', file: 'memberships.json', columns: ['id', 'creator_id', 'plan_id', 'plan_version_id', 'origin', 'joined_at', 'terms_since', 'updated_at'], order: 'joined_at' },
++ { table: 'vip_member_preferences', subject: 'member_subject', file: 'preferences.json', columns: ['creator_id', 'show_badge', 'listed', 'updated_at'], order: 'updated_at' },
++ { table: 'vip_entitlement_projection', subject: 'member_subject', file: 'entitlements.json', columns: ['creator_subject', 'kind', 'active', 'expires_at', 'cancel_at_period_end', 'subscription_status'], order: 'synced_at' },
++ { table: 'vip_checkouts', subject: 'member_subject', file: 'checkouts.json', columns: ['id', 'creator_id', 'plan_id', 'provider', 'status', 'created_at', 'updated_at'] },
++ { table: 'vip_membership_periods', subject: 'member_subject', file: 'paid-periods.json', columns: ['id', 'creator_subject', 'billing_transaction_id', 'period_start', 'period_end', 'status', 'recorded_at'], order: 'recorded_at', erase: { keep: 'paid periods are money records that reconcile with Billing\'s books' } },
++ { table: 'vip_membership_periods', subject: 'reversed_by', file: null, erase: anonymize },
++ { table: 'vip_creators', subject: 'subject', file: 'creator-profile.json', columns: ['id', 'username', 'display_name', 'bio', 'status', 'created_at', 'updated_at'], erase: { keep: 'plans, perks and other people\'s memberships reference the creator row; it is suspended and its name and bio removed' } },
++ { table: 'vip_plans', subject: 'created_by', file: 'plans.json', columns: ['id', 'creator_id', 'slug', 'status', 'billing_kind', 'latest_version', 'created_at', 'updated_at', 'archived_at'], erase: anonymize },
++ { table: 'vip_plan_versions', subject: 'created_by', file: 'plan-versions.json', columns: ['id', 'plan_id', 'version', 'name', 'description', 'benefits', 'terms', 'change_note', 'created_at', 'published_at'], erase: { keep: 'plan versions are the immutable terms members joined under' } },
++ { table: 'vip_perks', subject: 'created_by', file: 'perks.json', columns: ['id', 'creator_id', 'key', 'name', 'description', 'kind', 'status', 'created_at'], erase: anonymize },
++ { table: 'vip_gated_resource_rules', subject: 'created_by', file: null, erase: anonymize },
++];
++
++/** The creator row stays (it is referenced; counted as retained above) but is suspended and loses what names the person. */
++async function extraErase(t, subjects) {
++ await t.exec(`UPDATE vip_creators SET status = 'suspended', username = NULL, username_lc = NULL, display_name = NULL, bio = NULL,
++ updated_at = $2 WHERE kind = 'creator' AND subject = ANY($1::text[])`, [subjects, new Date().toISOString()]);
++}
++
++/** The account-data handle for VIP's database (domain.db). */
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db, service: 'vip', tables: TABLES, extraErase, log,
++ note: 'Payments, refunds and subscriptions are OpenVibe.Billing\'s records. Paid periods stay here for its books.',
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 2814deb..341eb27 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -24,9 +24,16 @@ const { http } = require('openvibe-contracts');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { isUserSubject } = require('../util');
++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
++
const CONSUMER = 'vip-billing';
--function consumerRouter({ domain, config, log = console }) {
++/**
++ * accountData + accountSend: network.account.export_requested and network.account.deleted (ADR-033, subscribed at
++ * boot) go to domain/account-data.js, outside the billing inbox: openvibe-sdk/account-data keeps its own receipt per
++ * export and deletion id and throws when Network should be asked again (answered 500, so Events redelivers).
++ */
++function consumerRouter({ domain, config, log = console, accountData = null, accountSend = null }) {
const router = express.Router();
const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql
const { entitlements, periods } = domain;
@@@ -87,6 +94,10 @@ function consumerRouter({ domain, config, log = console }) {
/** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */
async function apply(event) {
++ if (ACCOUNT_TOPICS.includes(event.event_type)) {
++ if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');
++ return { duplicate: false, outcome: await accountData.apply(event, { send: accountSend }) };
++ }
const r = await inbox.once(CONSUMER, event.event_id, async () => (event.source !== 'billing' ? 'ignored:source' : await handle(event)));
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
}
ddiff --git a/server/index.js b/server/index.js
iindex 8e9c469..14ad3db 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -11,6 +11,7 @@
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
* The process stop (openvibe-sdk/service, plan T1): the job timers and relays stop taking new work
@@@ -18,7 +19,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
* database closes; past the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported
* so a test can inject `exit` and `signals: false`.
*/
--function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
++function createLifecycle({ server, app, timers = [], exit, signals, extra = [] } = {}) {
const { domain, keys, outbox, metrics } = app.locals;
return gracefulStop({
name: 'VIP', server, deadlineExitCode: 0, exit, signals,
@@@ -27,6 +28,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
() => outbox.stop(),
() => keys.stop(),
() => { if (metrics && metrics.stop) metrics.stop(); },
++ ...extra,
],
close: [() => domain.db.close()],
});
@@@ -60,7 +62,13 @@ async function start() {
});
server.keepAliveTimeout = 65_000;
-- createLifecycle({ server, app, timers });
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // VIP_EVENTS_SECRET or the client secret. The billing.* ones stay scripts/subscribe.js's.
++ const subscriptions = startSubscriptions({
++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.webhookSecrets[0],
++ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ });
++ createLifecycle({ server, app, timers, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
return { server, app };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..11341db
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * ADR-033: VIP's part of an account export and of an account deletion, through the signed /internal/events route with
++ * a stand-in Network. A member's memberships, preferences, entitlement cache and checkouts go and their paid periods
++ * stay (Billing's books); a creator's row is suspended without its name, their plans lose created_by, and the plan
++ * versions members joined under stay whole. Each deletion is confirmed once.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, harness } = require('./helpers/app');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++
++const { test, run } = harness('account-data');
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_vip', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++const envelope = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'network' }, subject: { type: 'account', id: payload.subject }, visibility: 'internal', payload });
++
++(async () => {
++ const stub = await startNetworkStub();
++ const t = await boot({ accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'vip', clientSecret: 'shh' }) });
++ const db = t.domain.db;
++ const count = async (sql, args) => Number(await db.value(sql, args));
++ const creator = t.network.newUser('rena', { role: 'streamer' });
++ const member = t.network.newUser('mona');
++ const other = t.network.newUser('otto');
++ let plan;
++ let creatorId;
++
++ test('a creator publishes a plan and two members pay for it', async () => {
++ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } })).json.plan;
++ assert.ok(plan && plan.id);
++ creatorId = await db.value('SELECT id FROM vip_creators WHERE subject = $1', [creator.subject]);
++ await t.deliverAll(t.billing.pay(member.subject, creator.subject).events);
++ await t.deliverAll(t.billing.pay(other.subject, creator.subject).events);
++ await db.exec(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES ($1, $2, 1, 1, $3)
++ ON CONFLICT (member_subject, creator_id) DO NOTHING`, [member.subject, creatorId, new Date().toISOString()]);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [member.subject]), 1);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [member.subject]), 1);
++ });
++
++ test('the member\'s export carries their memberships, entitlements and paid periods, and nobody else\'s', async () => {
++ const r = await t.deliver(envelope('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: member.subject }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
++ assert.strictEqual(part.auth, 'Bearer tok_vip');
++ const names = part.body.files.map((f) => f.name);
++ for (const f of ['entitlements.json', 'paid-periods.json', 'preferences.json']) assert.ok(names.includes(f), `${f} in ${names}`);
++ assert.ok(!JSON.stringify(part.body).includes(other.subject));
++ });
++
++ test('deleting the member removes their memberships, preferences and cache, keeps the paid period, and confirms once', async () => {
++ const body = envelope('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: member.subject });
++ assert.strictEqual((await t.deliver(body)).status, 200);
++ const m = [member.subject];
++ assert.strictEqual(await count('SELECT count(*) FROM vip_memberships WHERE member_subject = $1', m), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_member_preferences WHERE member_subject = $1', m), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', m), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_checkouts WHERE member_subject = $1', m), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', m), 1, 'the paid period stays for Billing\'s books');
++ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [other.subject]), 1, 'otto keeps his');
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.strictEqual(conf[0].body.retained.vip_membership_periods, 1);
++ assert.strictEqual(conf[0].body.erased.vip_entitlement_projection, 1);
++ assert.strictEqual((await t.deliver(body)).json.outcome, 'unchanged');
++ });
++
++ test('deleting the creator suspends their row without its name, keeps the plan versions, and leaves members\' periods', async () => {
++ const r = await t.deliver(envelope('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE2', subject: creator.subject }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ const row = await db.maybe('SELECT * FROM vip_creators WHERE id = $1', [creatorId]);
++ assert.deepStrictEqual([row.status, row.username, row.display_name, row.bio], ['suspended', null, null, null]);
++ assert.strictEqual(await count('SELECT count(*) FROM vip_plans WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1);
++ assert.ok(await count('SELECT count(*) FROM vip_plan_versions WHERE plan_id = $1', [plan.id]) >= 1, 'the terms members joined under stay');
++ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [other.subject]), 1);
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE2/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.strictEqual(conf[0].body.retained.vip_creators, 1);
++ });
++
++ try { await run(); } finally { await t.close(); await stub.close(); }
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex eace4ef..942ee55 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -47,7 +47,7 @@ async function boot(opts = {}) {
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };
// One database per boot (PGlite, or VIP_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = opts.testdb || await require('./db').testDb();
-- const app = await createApp({ config, db: testdb.db, now: clock.now, log, indexnow: opts.indexnow });
++ const app = await createApp({ config, db: testdb.db, now: clock.now, log, indexnow: opts.indexnow, accountSend: opts.accountSend });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const domain = app.locals.domain;