openvibers/openvibe.billing Commits

Add the universal wallet, idempotent checkout and store spending (#228)

OP OpenVibers committed 4 days ago · c09ccef4b0 · 20 files, +596 −6

* Add one authenticated wallet read across Billing and Network

* Authorize store spends and idempotent wallet checkout starts

* Respect the owner money switch across wallet actions

* Capture approved PayPal wallet returns without duplicate settlement

* Wallet offers name the hosts their hosted checkout may use

GET /api/v1/me/wallet's checkout.providers now returns [{ name, hosts }]: each enabled
provider names the host its hosted checkout page lives on (Stripe checkout.stripe.com, PayPal
www.paypal.com or its sandbox host, CCBill api.ccbill.com, NOWPayments nowpayments.io). The
shared wallet sheet follows a checkout_url only to a named host or to Billing itself, and
refuses anything else, so a provider answer can no longer send the browser anywhere.

The per-provider host list comes from each adapter (checkoutHosts); an adapter without one
offers an empty list and the sheet fails closed.

Changes

ddiff --git a/docs/wallet.md b/docs/wallet.md
nnew file mode 100644
iindex 0000000..e28b586
---- /dev/null
++++ b/docs/wallet.md
@@@ -0,0 +1,13 @@
++# Universal wallet
++
++`GET /api/v1/me/wallet` accepts a personal Network bearer token and returns all three balances, their currency and purpose, available checkout offers (each provider with the hosts its hosted checkout may use, so the sheet follows only a named host), the latest five Billing/Network events with source sites, current-month AI usage and both the Billing freeze and Live’s owner money_writes_frozen switch. Available Balance is denominated in USD at nano-USD precision; there is no invented FX conversion. OpenCoins remains on Network's ledger: Billing forwards the verified person's bearer only to configured Network read endpoints, without cookies or redirects. Upstream errors are failures, never zero balances. First-party origins receive bearer CORS; hosting tenants do not.
++
++`POST /api/v1/me/vibes/buy` takes `{provider,vibes,idempotency_key}`. `POST /api/v1/me/wallet/topups` takes `{provider,amount_cents,idempotency_key}`. Both fix the buyer to the token, compute prices/fees server-side, respect the freeze, and return the existing hosted checkout URL. The sheet navigates in the same tab. A durable claim is saved before contacting the provider; completed requests replay the original checkout and changed payloads fail. An uncertain provider result stays pending and requires confirmation before another payment is started. Existing provider webhooks settle the original intent.
++
++`POST /api/v1/me/store/spend` takes only `{purchase_id}`. Billing obtains the immutable order from configured Inventory with the person's bearer. Vibes spending and its receipt commit atomically, with funds and freeze checks. OpenCoins debits go to Network with Billing's service token and a deterministic order key; an uncertain reply is recovered by replaying that key. Inventory grants only after matching the confirmed payment. Ledger writes stay in their current authoritative service; Inventory has no balance copy.
++
++PayPal returns to Billing, which captures the approved server-owned order with a stable provider request key and settles through the existing receipt path. Reloading a completed return does not capture again. Other providers settle through their verified webhooks. A failed return asks the buyer to check the same payment, never to create another one.
++
++The owner switch is read from LIVE_URL (default https://openvibe.live) at /api/payments/wallet-state. If that read fails, balances remain readable and wallet writes pause.
++
++Rollout prerequisites: `INVENTORY_URL` defaults to Inventory's public origin. Network must grant Billing `network.coins.debit` for audience `openvibe.network`, scoped to app `billing`, before OpenCoins purchases work. The existing owner settings must enable and configure checkout providers and top-ups. Consuming sites must allow `https://billing.openvibe.network` and `https://inventory.openvibe.network` in connect-src when pinning the Shared wallet release. Nothing here changes production grants, prices, settings or deployment.
ddiff --git a/migrations/0017_wallet_store.sql b/migrations/0017_wallet_store.sql
nnew file mode 100644
iindex 0000000..ac7205c
---- /dev/null
++++ b/migrations/0017_wallet_store.sql
@@@ -0,0 +1,21 @@
++-- phase: expand
++-- Immutable Inventory order snapshots and recoverable Network OpenCoins debits.
++CREATE TABLE wallet_store_payments (
++ purchase_id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ currency text NOT NULL CHECK (currency IN ('vibes', 'coins')),
++ amount bigint NOT NULL CHECK (amount > 0),
++ item_id text NOT NULL,
++ name text NOT NULL,
++ payment_id text,
++ created_at timestamptz NOT NULL
++);
++CREATE INDEX wallet_store_payments_subject ON wallet_store_payments(subject);
++CREATE TABLE wallet_checkouts (
++ subject text COLLATE "C" NOT NULL,
++ idempotency_key text NOT NULL,
++ request_hash text NOT NULL,
++ response jsonb,
++ created_at timestamptz NOT NULL,
++ PRIMARY KEY(subject, idempotency_key)
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 0d27804..460b28d 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -19,6 +19,8 @@ const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const ledger = 'money records: the ledger must still balance and reconcile, and payments are records the law requires us to keep';
const TABLES = [
++ { table: 'wallet_store_payments', subject: 'subject', file: 'store-payments.json', columns: ['purchase_id', 'currency', 'amount', 'item_id', 'name', 'payment_id', 'created_at'], erase: { keep: ledger } },
++ { table: 'wallet_checkouts', subject: 'subject', file: 'wallet-checkouts.json', columns: ['idempotency_key', 'created_at'], erase: { keep: ledger } },
{ table: 'transactions', subject: 'from_subject', file: 'transactions-sent.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger } },
{ table: 'transactions', subject: 'to_subject', file: 'transactions-received.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger }, kind: 'transactions_received' },
{ table: 'payment_intents', subject: 'subject', file: 'checkouts.json', columns: ['id', 'provider', 'kind', 'streamer_subject', 'amount_cents', 'fee_cents', 'bits', 'route', 'auto_renew', 'status', 'settled_txn', 'created_at', 'updated_at'], erase: { keep: ledger } },
ddiff --git a/server/api/wallet.js b/server/api/wallet.js
nnew file mode 100644
iindex 0000000..487961e
---- /dev/null
++++ b/server/api/wallet.js
@@@ -0,0 +1,61 @@
++'use strict';
++
++const express = require('express');
++const { http, ids } = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++const { firstPartyOrigins } = require('./me');
++const { wallet } = require('../ops/wallet');
++
++function walletRouter({ ctx, config, keys, limits, adapters }) {
++ const r = express.Router();
++ const store = require('../ops/store').createStoreSpender(ctx);
++ const checkout = require('../ops/wallet-checkout');
++ r.use((req, res, next) => {
++ const origin = req.get('origin');
++ res.set({ 'Cache-Control': 'private, no-store', Vary: 'Origin' });
++ if (origin && firstPartyOrigins().has(origin)) res.set({
++ 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Traceparent, X-OpenVibe-Request-Id, Idempotency-Key',
++ 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Max-Age': '600',
++ });
++ if (req.method === 'OPTIONS') return res.sendStatus(204);
++ return next();
++ });
++ r.use(async (req, res, next) => {
++ const auth = String(req.get('authorization') || '');
++ if (!auth.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing');
++ try {
++ const claims = await verifyUserToken(auth.slice(7).trim(), { ...keys.verifyOptions, issuer: config.network.issuer, now: ctx.now() });
++ if (!ids.isSubjectId('user', claims.subject_id)) return http.sendProblem(res, 401, 'token.invalid');
++ req.person = { subject: claims.subject_id, networkUserId: claims.id || claims.sub };
++ return next();
++ } catch (e) {
++ return http.sendProblem(res, e.code === 'token.unavailable' ? 503 : 401, e.code || 'token.invalid');
++ }
++ });
++ r.get('/wallet', limits.selfRead, async (req, res, next) => {
++ try { return res.json({ ...(await wallet(ctx, req.person.subject, req.get('authorization'))), checkout: checkout.offers(ctx, adapters) }); }
++ catch (e) {
++ if (e.status && e.code) return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message });
++ return next(e);
++ }
++ });
++ r.post('/store/spend', express.json({ limit: '4kb' }), limits.budget('billing.intent.create', (req) => req.person.subject), async (req, res, next) => {
++ try { return res.json(await store.spend(req.person.subject, req.person.networkUserId, req.get('authorization'), (req.body || {}).purchase_id)); }
++ catch (e) {
++ if (e.status && e.code) return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message });
++ return next(e);
++ }
++ });
++ for (const [path, kind] of [['/vibes/buy', 'vibes'], ['/wallet/topups', 'topup']]) {
++ r.post(path, express.json({ limit: '4kb' }), limits.budget('billing.intent.create', (req) => req.person.subject), async (req, res, next) => {
++ try { return res.json(await checkout.checkout(ctx, adapters, req.person.subject, kind, req.body || {})); }
++ catch (e) {
++ if (e.status && e.code) return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message });
++ return next(e);
++ }
++ });
++ }
++ return r;
++}
++
++module.exports = { walletRouter };
ddiff --git a/server/app.js b/server/app.js
iindex 152fb8a..790990e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -145,11 +145,22 @@ async function createApp(opts = {}) {
// The registry carries the counters: the number of per-person calls that still come without an
// assertion is what the flag plan waits for.
const subjectAssertion = require('./api/subject-assertion').createSubjectAssertion({ config, log, registry: metrics.registry });
++ app.use('/api/v1/me', require('./api/wallet').walletRouter({ ctx, config, keys, limits, adapters }));
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits, subjectAssertion }));
// A signed-in person's own Vibes, for the shared navbar on every OpenVibe site (api/me.js; user tokens, CORS for owned sites).
app.use('/api/me', require('./api/me').meRouter({ ctx, config, keys, limits, adapters }));
// The billing policy is public and indexable; the staff console and the API are not.
++ app.get('/checkout/cancel', (_req, res) => res.redirect(303, 'https://openvibe.network/wallet'));
++ app.get('/checkout/success', async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ await require('./ops/wallet-checkout').completeReturn(ctx, adapters, req.query.token);
++ return res.redirect(303, 'https://openvibe.network/wallet');
++ } catch (e) {
++ return res.status(e.status || 502).type('text').send('Your payment is not confirmed yet. Reload this page to check the same payment. Do not start another payment.');
++ }
++ });
app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nAllow: /policy\nDisallow: /\n'));
app.use(require('./policy').policyRouter({ config }));
app.use(consoleRouter({ ctx, adapters, keys, fetchImpl }));
ddiff --git a/server/config.js b/server/config.js
iindex d9cf572..77aedb7 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -198,6 +198,9 @@ function loadConfig(env = process.env) {
// fails reconciliation: the provider was paid and the ledger does not show it yet.
staleReceiptMin: int(env.BILLING_RECONCILE_STALE_RECEIPT_MIN, 60),
},
++ liveUrl: String(env.LIVE_URL || 'https://openvibe.live').replace(/\/+$/, ''),
++ inventoryUrl: String(env.INVENTORY_URL || 'https://inventory.openvibe.network').replace(/\/+$/, ''),
++
credits: {
// AI Exchange D2/D12: high-precision Balance ledger. Off by default; no inference money moves without it.
enabled: bool(env.BILLING_CREDITS),
ddiff --git a/server/ops/activity.js b/server/ops/activity.js
iindex dc70ee9..ce8c82a 100644
---- a/server/ops/activity.js
++++ b/server/ops/activity.js
@@@ -55,6 +55,7 @@ function labelFor(t, { settlement, projectName, creatorName }) {
const name = app ? String(app).slice(0, APP_NAME_MAX) : null;
return { label: name ? `Paid ${name}` : 'Paid an app', detail: m.description ? String(m.description).slice(0, 120) : '' };
}
++ if (t.type === 'purchase' && m.store_purchase) return { label: `Bought ${String(m.item_name || 'an item').slice(0, 120)}`, detail: 'Inventory' };
if (t.type === 'purchase') {
const n = Number(m.bits || m.vibes || m.amount_bits || 0);
return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };
ddiff --git a/server/ops/store.js b/server/ops/store.js
nnew file mode 100644
iindex 0000000..330fff2
---- /dev/null
++++ b/server/ops/store.js
@@@ -0,0 +1,78 @@
++'use strict';
++
++const { serviceAuth } = require('openvibe-contracts');
++const { A, entry, fail, assertNotFrozen } = require('./common');
++const { money, post, requireFunds } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { summary } = require('./purchases');
++
++function createStoreSpender(ctx) {
++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${ctx.config.network.internalUrl}/oauth/token`,
++ clientId: ctx.config.oauth.clientId, clientSecret: ctx.config.oauth.clientSecret, audience: 'openvibe.network',
++ scope: 'network.coins.debit', fetchImpl: ctx.fetchImpl });
++ const present = (p) => ({ purchase_id: p.purchase_id, payment_id: p.payment_id, currency: p.currency, amount: Number(p.amount) });
++ async function spend(subject, networkUserId, authorization, id) {
++ if (!/^isp_[0-9A-HJKMNP-TV-Z]{26}$/.test(id || '')) fail(422, 'billing.invalid_purchase', 'purchase_id is required');
++ let saved = await ctx.db.prepare('SELECT * FROM wallet_store_payments WHERE purchase_id = ?').get(id);
++ if (saved && saved.subject !== subject) fail(404, 'billing.purchase_not_found', 'No such purchase');
++ if (saved && saved.payment_id) return present(saved);
++ if (ctx.config.authority !== 'billing') fail(409, 'billing.not_authoritative', 'Billing is not the active ledger');
++ await require('./wallet-freeze').assertWalletWritable(ctx);
++ if (!saved) {
++ let quote;
++ try {
++ const r = await ctx.fetchImpl(`${ctx.config.inventoryUrl}/api/v1/store/purchases/${id}`, {
++ headers: { Authorization: authorization }, credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(5000) });
++ if (!r.ok) throw new Error('No quote');
++ quote = await r.json();
++ } catch { fail(502, 'billing.inventory_unavailable', 'Could not verify the store order'); }
++ if (quote.id !== id || quote.subject !== subject || !['coins', 'vibes'].includes(quote.currency) ||
++ !Number.isSafeInteger(quote.amount) || quote.amount < 1 || quote.amount > ctx.rates.maxBits ||
++ !/^itd_[0-9A-HJKMNP-TV-Z]{26}$/.test(quote.item_id || '') || typeof quote.name !== 'string') {
++ fail(422, 'billing.invalid_purchase', 'Inventory returned an invalid order');
++ }
++ await ctx.db.prepare(`INSERT INTO wallet_store_payments (purchase_id, subject, currency, amount, item_id, name, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`)
++ .run(id, subject, quote.currency, quote.amount, quote.item_id, quote.name.slice(0, 120), new Date(ctx.now()).toISOString());
++ saved = await ctx.db.prepare('SELECT * FROM wallet_store_payments WHERE purchase_id = ?').get(id);
++ if (saved.subject !== subject) fail(404, 'billing.purchase_not_found', 'No such purchase');
++ }
++ if (saved.currency === 'vibes') {
++ return money(ctx.db, async () => {
++ await assertNotFrozen(ctx);
++ const row = await ctx.db.prepare('SELECT * FROM wallet_store_payments WHERE purchase_id = ? FOR UPDATE').get(id);
++ if (row.payment_id) return present(row);
++ const amount = Number(row.amount);
++ await requireFunds(ctx.db, A.credit(subject), amount, 'Vibes');
++ const { txn } = await post(ctx, { type: 'purchase', idempotencyKey: `store:${id}`, fromSubject: subject,
++ actor: { type: 'user', id: subject }, entries: [entry(A.credit(subject), -amount), entry(A.revenue('vibes-bits'), amount)],
++ metadata: { store_purchase: id, item_id: row.item_id, item_name: row.name, site: 'inventory', amount_bits: amount } });
++ await ctx.db.prepare('UPDATE wallet_store_payments SET payment_id = ? WHERE purchase_id = ?').run(txn.id, id);
++ await enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ return present({ ...row, payment_id: txn.id });
++ });
++ }
++ if (!Number.isSafeInteger(Number(networkUserId)) || Number(networkUserId) < 1) fail(401, 'token.invalid', 'The token has no Network user id');
++ // The debit stays in Network's ledger. The durable order and deterministic key recover an uncertain response.
++ let r, data;
++ try {
++ r = await ctx.fetchImpl(`${ctx.config.network.internalUrl}/internal/coins/debit`, { method: 'POST',
++ headers: { ...(await tokens.authHeaders()), 'Content-Type': 'application/json' },
++ credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(10000),
++ body: JSON.stringify({ user_id: Number(networkUserId), app_id: 'billing', amount: Number(saved.amount),
++ reason: `Bought ${saved.name} at Inventory`, ref: id, idempotency_key: `billing-store:${id}` }) });
++ data = await r.json();
++ } catch { fail(504, 'billing.payment_unknown', 'Retry this purchase to recover its original payment'); }
++ if (!r.ok) {
++ if (r.status === 409 && data.error === 'insufficient_funds') fail(402, 'billing.insufficient_funds', 'Not enough OpenCoins');
++ fail(502, 'billing.coins_unavailable', 'OpenCoins payment is unavailable; retry this purchase');
++ }
++ if (!Number.isSafeInteger(data.balance) || data.balance < 0) fail(502, 'billing.payment_unknown', 'Invalid OpenCoins confirmation; retry this purchase');
++ const paymentId = `coins:${id}`;
++ await ctx.db.prepare('UPDATE wallet_store_payments SET payment_id = ? WHERE purchase_id = ?').run(paymentId, id);
++ return present({ ...saved, payment_id: paymentId });
++ }
++ return { spend };
++}
++
++module.exports = { createStoreSpender };
ddiff --git a/server/ops/wallet-checkout.js b/server/ops/wallet-checkout.js
nnew file mode 100644
iindex 0000000..955bec0
---- /dev/null
++++ b/server/ops/wallet-checkout.js
@@@ -0,0 +1,73 @@
++'use strict';
++const crypto = require('crypto');
++const { fail, positiveInt } = require('./common');
++const { idempotencyKey } = require('./balance');
++const { topupTerms } = require('./credits');
++const intents = require('./intents');
++
++function offers(ctx, adapters) {
++ const terms = topupTerms(ctx);
++ return {
++ // Each provider names the hosts its hosted checkout may use; the wallet follows a checkout_url only to one
++ // of them (or to Billing itself) and refuses anything else.
++ providers: Object.values(adapters).filter((a) => a.enabled && a.createCheckout).map((a) => ({ name: a.name, hosts: Array.isArray(a.checkoutHosts) ? a.checkoutHosts : [] })),
++ vibes: { minimum: ctx.rates.minPurchaseBits, maximum: ctx.rates.maxBits, price_tiers: ctx.rates.priceTiers },
++ topup: { enabled: !!(ctx.config.credits.enabled && terms.enabled && terms.configured),
++ amounts_cents: terms.configured ? terms.amountsCents : [], fee_bps: terms.feeBps },
++ };
++}
++
++async function checkout(ctx, adapters, subject, kind, input) {
++ const key = idempotencyKey(input.idempotency_key);
++ const provider = String(input.provider || '');
++ const amount = positiveInt(kind === 'vibes' ? input.vibes : input.amount_cents, kind === 'vibes' ? 'vibes' : 'amount_cents');
++ const hash = crypto.createHash('sha256').update(JSON.stringify({ kind, provider, amount })).digest('hex');
++ const old = await ctx.db.prepare('SELECT * FROM wallet_checkouts WHERE subject = ? AND idempotency_key = ?').get(subject, key);
++ if (old) {
++ if (old.request_hash !== hash) fail(409, 'billing.key_reused', 'This key names a different checkout');
++ if (old.response) return typeof old.response === 'string' ? JSON.parse(old.response) : old.response;
++ fail(409, 'billing.checkout_pending', 'The original checkout is being confirmed. Do not start another payment.');
++ }
++ if (ctx.config.authority !== 'billing') fail(409, 'billing.not_authoritative', 'Billing is not the active ledger');
++ await require('./wallet-freeze').assertWalletWritable(ctx);
++ const adapter = adapters[provider];
++ if (!adapter || !adapter.enabled || !adapter.createCheckout) fail(409, 'billing.provider_disabled', 'Choose an available checkout provider');
++ const available = offers(ctx, adapters);
++ if (kind === 'vibes' && (amount < available.vibes.minimum || amount > available.vibes.maximum)) fail(422, 'billing.invalid_amount', 'Choose an offered Vibes amount');
++ if (kind === 'topup' && (!available.topup.enabled || !available.topup.amounts_cents.includes(amount))) fail(422, 'billing.amount_not_allowed', 'Choose an offered top-up');
++ const claimed = await ctx.db.prepare(`INSERT INTO wallet_checkouts (subject, idempotency_key, request_hash, created_at)
++ VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING subject`).get(subject, key, hash, new Date(ctx.now()).toISOString());
++ if (!claimed) fail(409, 'billing.checkout_pending', 'The original checkout is being confirmed');
++ // Persist the claim before contacting a provider. An uncertain result is never retried as a second checkout.
++ let result;
++ if (kind === 'topup') {
++ result = await require('./topups').startTopup(ctx, adapters, { subject, provider, amount_cents: amount });
++ } else {
++ const intent = await intents.create(ctx, { subject, provider, kind: 'purchase', bits: amount });
++ const opened = await adapter.createCheckout(ctx, intent, { name: `${amount} Vibes`,
++ successUrl: `${ctx.config.baseUrl}/checkout/success`, cancelUrl: `${ctx.config.baseUrl}/checkout/cancel`,
++ ipnUrl: `${ctx.config.baseUrl}/webhooks/${adapter.name}` });
++ if (opened.providerRef) await intents.setProviderRef(ctx, intent.id, opened.providerRef);
++ result = { intent: intents.present(await intents.find(ctx.db, intent.id)), checkout_url: opened.url || null };
++ }
++ await ctx.db.prepare('UPDATE wallet_checkouts SET response = ? WHERE subject = ? AND idempotency_key = ?')
++ .run(JSON.stringify(result), subject, key);
++ return result;
++}
++
++async function completeReturn(ctx, adapters, token) {
++ if (token == null) return; // Providers with webhook settlement need no browser capture.
++ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{6,128}$/.test(token)) fail(422, 'billing.invalid_order', 'Invalid payment return');
++ const intent = await intents.findByProviderRef(ctx.db, 'paypal', token);
++ if (!intent) fail(404, 'billing.intent_not_found', 'Payment order not found');
++ if (intent.settled_txn) return;
++ await require('./wallet-freeze').assertWalletWritable(ctx);
++ const adapter = adapters.paypal;
++ if (!adapter || !adapter.enabled || !adapter.capture) fail(409, 'billing.provider_disabled', 'PayPal is unavailable');
++ // The token only selects a server-owned intent. PayPal verifies the buyer's approval and the paid amount.
++ const plan = await adapter.capture(ctx, intent);
++ if (!['purchase', 'credits_topup'].includes(plan.effect)) fail(409, 'billing.payment_pending', 'Payment is not confirmed yet');
++ await require('../ledger').money(ctx.db, () => require('../providers').applyPlan(ctx, plan, { id: null }));
++}
++
++module.exports = { offers, checkout, completeReturn };
ddiff --git a/server/ops/wallet-freeze.js b/server/ops/wallet-freeze.js
nnew file mode 100644
iindex 0000000..c70c8cf
---- /dev/null
++++ b/server/ops/wallet-freeze.js
@@@ -0,0 +1,22 @@
++'use strict';
++const { isFrozen, fail } = require('./common');
++
++// Live's owner switch predates the universal wallet. Preserve it alongside Billing's ledger freeze.
++async function walletFrozen(ctx) {
++ if (await isFrozen(ctx.db)) return true;
++ try {
++ const r = await ctx.fetchImpl(`${ctx.config.liveUrl}/api/payments/wallet-state`, {
++ credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(3000),
++ });
++ const state = await r.json();
++ if (!r.ok || typeof state.money_writes_frozen !== 'boolean') throw new Error('Unknown freeze state');
++ return state.money_writes_frozen;
++ } catch {
++ // Reads still work, but no checkout or store payment may bypass an unavailable owner switch.
++ return true;
++ }
++}
++async function assertWalletWritable(ctx) {
++ if (await walletFrozen(ctx)) fail(503, 'billing.frozen', 'Purchases and top-ups are paused');
++}
++module.exports = { walletFrozen, assertWalletWritable };
ddiff --git a/server/ops/wallet.js b/server/ops/wallet.js
nnew file mode 100644
iindex 0000000..76a7568
---- /dev/null
++++ b/server/ops/wallet.js
@@@ -0,0 +1,67 @@
++'use strict';
++
++const { balance } = require('../ledger');
++const { A, fail } = require('./common');
++const { presentBalance } = require('./balance');
++const { activity } = require('./activity');
++
++// Network remains authoritative for OpenCoins. Never request with=vibes here: that asks Billing again.
++async function coinsRead(ctx, authorization, path) {
++ try {
++ const r = await ctx.fetchImpl(`${ctx.config.network.internalUrl}/api/coins/me${path}`, {
++ headers: { Authorization: authorization, Accept: 'application/json' },
++ credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(5000),
++ });
++ if (!r.ok) throw new Error('Network refused the wallet read');
++ return await r.json();
++ } catch {
++ fail(502, 'billing.coins_unavailable', 'OpenCoins is temporarily unavailable; try again');
++ }
++}
++
++async function wallet(ctx, subject, authorization) {
++ if (ctx.config.authority !== 'billing') fail(409, 'billing.not_authoritative', 'Billing is not the active ledger');
++ const [coins, history, vibes, personal, recent, frozen, ai] = await Promise.all([
++ coinsRead(ctx, authorization, ''), coinsRead(ctx, authorization, '/history?limit=5&offset=0'),
++ balance(ctx.db, A.credit(subject)), presentBalance(ctx, subject), activity(ctx, subject, { limit: 5 }),
++ require('./wallet-freeze').walletFrozen(ctx),
++ ctx.db.prepare(`SELECT 1 AS used FROM credit_settlements s JOIN credit_holds h ON h.id = s.hold_id
++ WHERE s.subject = ? AND h.service = 'ai' AND s.created_at >= ? LIMIT 1`)
++ .get(subject, new Date(new Date(ctx.now()).toISOString().slice(0, 7) + '-01T00:00:00Z').toISOString()),
++ ]);
++ if (!Number.isSafeInteger(coins.balance) || coins.balance < 0 || !Array.isArray(history.transactions)) {
++ fail(502, 'billing.coins_unavailable', 'Network returned an invalid OpenCoins wallet');
++ }
++ const events = recent.activity.map((e) => ({ ...e, amount: String(e.amount), site: 'billing' }));
++ const ids = [...new Set(events.map((e) => e.id))];
++ if (ids.length) {
++ const rows = await ctx.db.prepare(`SELECT id, metadata FROM transactions WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids);
++ for (const row of rows) {
++ const m = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata || {};
++ const site = String(m.site || m.service || m.app_name || (m.credit === 'settle' ? 'ai' : 'billing')).slice(0, 80);
++ for (const e of events) if (e.id === row.id) e.site = site;
++ }
++ }
++ for (const e of history.transactions) {
++ if (!Number.isSafeInteger(Number(e.delta)) || !Number.isFinite(Date.parse(e.created_at))) {
++ fail(502, 'billing.coins_unavailable', 'Network returned invalid OpenCoins activity');
++ }
++ events.push({ id: `coins:${e.id}`, at: new Date(e.created_at).toISOString(), kind: 'coins',
++ amount: String(e.delta), label: String(e.reason || 'OpenCoins').slice(0, 120), detail: '', site: String(e.app_id || 'network').slice(0, 80) });
++ }
++ events.sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || b.id.localeCompare(a.id));
++ return {
++ subject: { type: 'user', id: subject }, currency: 'USD', money_writes_frozen: frozen, used_ai_this_month: !!ai,
++ balances: {
++ coins: { label: 'OpenCoins', amount: String(coins.balance), currency: 'opencoins', scale: 0,
++ purpose: 'Earned by taking part; spend on items across OpenVibe.', action: 'How to earn OpenCoins' },
++ vibes: { label: 'Vibes', amount: String(vibes), currency: 'vibes-bits', scale: 0,
++ purpose: 'Support creators and buy items across OpenVibe.', action: 'Buy Vibes' },
++ balance: { label: 'Balance', amount: personal.available_nano_usd, currency: 'USD', scale: 9,
++ held: personal.held_nano_usd, purpose: 'Real money for AI and paid features.', action: 'Top up' },
++ },
++ bits_per_usd: ctx.rates.bitsPerUsd, recent_activity: events.slice(0, 5),
++ };
++}
++
++module.exports = { wallet };
ddiff --git a/server/providers/ccbill.js b/server/providers/ccbill.js
iindex 390aa3b..32f90ac 100644
---- a/server/providers/ccbill.js
++++ b/server/providers/ccbill.js
@@@ -78,7 +78,9 @@ function createCcbill(cfg) {
return { url: `https://api.ccbill.com/wap-frontflex/flexforms/${cfg.flexformId}?${qs}`, providerRef: null };
}
-- return { name: 'ccbill', enabled, verify, parse, interpret, createCheckout };
++ // The hosted flexform page the wallet may follow to.
++ const checkoutHosts = ['api.ccbill.com'];
++ return { name: 'ccbill', enabled, verify, parse, interpret, createCheckout, checkoutHosts };
}
module.exports = { createCcbill };
ddiff --git a/server/providers/nowpayments.js b/server/providers/nowpayments.js
iindex 19e899b..9f98f2d 100644
---- a/server/providers/nowpayments.js
++++ b/server/providers/nowpayments.js
@@@ -69,7 +69,9 @@ function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {
return { url: j.invoice_url, providerRef: String(j.id || j.invoice_id || '') || null };
}
-- return { name: 'nowpayments', enabled, verify, parse, interpret, createCheckout };
++ // The hosted invoice page the wallet may follow to (invoice_url), never the API host.
++ const checkoutHosts = ['nowpayments.io', 'www.nowpayments.io'];
++ return { name: 'nowpayments', enabled, verify, parse, interpret, createCheckout, checkoutHosts };
}
module.exports = { createNowpayments, sortObject };
ddiff --git a/server/providers/paypal.js b/server/providers/paypal.js
iindex ba64f9b..ff7315d 100644
---- a/server/providers/paypal.js
++++ b/server/providers/paypal.js
@@@ -101,14 +101,16 @@ function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
/** Capture an approved order; returns a settlement plan built from the capture. */
async function capture(ctx, intent) {
const j = await json(`${cfg.apiBase}/v2/checkout/orders/${encodeURIComponent(intent.provider_ref)}/capture`, {
-- method: 'POST', headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' }, body: '{}',
++ method: 'POST', headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json', 'PayPal-Request-Id': intent.id }, body: '{}',
});
const cap = j.purchase_units && j.purchase_units[0] && j.purchase_units[0].payments && j.purchase_units[0].payments.captures && j.purchase_units[0].payments.captures[0];
if (j.status !== 'COMPLETED' || !cap) return { effect: 'none', reason: `order ${j.status}` };
return await settlePlan(ctx, { ...cap, custom_id: cap.custom_id || intent.id }, `paypal-capture:${cap.id}`, { principal: 'provider:paypal', capture: cap.id });
}
-- return { name: 'paypal', enabled, verify, parse, interpret, createCheckout, capture };
++ // The approval page the wallet may follow to, matching the environment the API base names.
++ const checkoutHosts = /sandbox/i.test(String(cfg.apiBase || '')) ? ['www.sandbox.paypal.com'] : ['www.paypal.com'];
++ return { name: 'paypal', enabled, verify, parse, interpret, createCheckout, capture, checkoutHosts };
}
module.exports = { createPaypal };
ddiff --git a/server/providers/stripe.js b/server/providers/stripe.js
iindex d869be5..d889250 100644
---- a/server/providers/stripe.js
++++ b/server/providers/stripe.js
@@@ -158,7 +158,9 @@ function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
return await api('POST', `/subscriptions/${encodeURIComponent(stripeSubscriptionId)}`, p);
}
-- return { name: 'stripe', get enabled() { return isEnabled(); }, verify, parse, interpret, createCheckout, cancelAtPeriodEnd };
++ // The hosted checkout page the wallet may follow to (the session's url), never the API host.
++ const checkoutHosts = ['checkout.stripe.com'];
++ return { name: 'stripe', get enabled() { return isEnabled(); }, verify, parse, interpret, createCheckout, cancelAtPeriodEnd, checkoutHosts };
}
module.exports = { createStripe };
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex a6c315f..e368b90 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -18,7 +18,7 @@ async function boot(opts = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'billing-test-'));
const env = {
NODE_ENV: 'test',
-- OV_NETWORK_INTERNAL_URL: network.url,
++ OV_NETWORK_INTERNAL_URL: network.url, LIVE_URL: network.url,
OV_NETWORK_ISSUER: network.url,
OV_OAUTH_CLIENT_ID: 'billing',
OV_OAUTH_CLIENT_SECRET: 'shh',
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 8735892..76203ae 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -111,6 +111,7 @@ async function startNetwork() {
}
const server = http.createServer(async (req, res) => {
++ if (req.url === '/api/payments/wallet-state') return send(res, 200, { money_writes_frozen: !!state.moneyFrozen });
const raw = await readBody(req);
if (req.url === '/api/.well-known/jwks') {
// Both keys, as Network publishes them: the RS256 session key and the ES256 assertion key.
ddiff --git a/test/wallet-payments.test.js b/test/wallet-payments.test.js
nnew file mode 100644
iindex 0000000..a849488
---- /dev/null
++++ b/test/wallet-payments.test.js
@@@ -0,0 +1,108 @@
++'use strict';
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, fund, check, done } = require('./helpers/app');
++const { createStoreSpender } = require('../server/ops/store');
++const { checkout } = require('../server/ops/wallet-checkout');
++
++(async () => {
++ const t = await boot({ stripe: true, env: { BILLING_AUTHORITY: 'billing', BILLING_CREDITS: '1',
++ BILLING_CREDITS_TOPUP: '1', BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: '0' } });
++ const alice = t.user(401), bob = t.user(402);
++ const token = t.network.signUser({ subject_id: alice.id });
++ await fund(t, alice.id, 100);
++ const orders = new Map(), debited = new Map();
++ let loseResponse = false;
++ const realFetch = t.ctx.fetchImpl;
++ t.ctx.fetchImpl = async (url, options) => {
++ if (url.includes('/store/purchases/')) {
++ assert.strictEqual(options.headers.Authorization, `Bearer ${token}`);
++ return { ok: true, json: async () => orders.get(url.split('/').pop()) };
++ }
++ if (url.endsWith('/internal/coins/debit')) {
++ const b = JSON.parse(options.body);
++ assert.strictEqual(b.user_id, 42); assert.strictEqual(b.app_id, 'billing');
++ debited.set(b.idempotency_key, b);
++ if (loseResponse) { loseResponse = false; throw new Error('lost payment reply'); }
++ return { ok: true, json: async () => ({ balance: 20 }) };
++ }
++ return realFetch(url, options);
++ };
++ const spender = createStoreSpender(t.ctx);
++ const order = (currency, amount, subject = alice.id) => {
++ const id = `isp_${ids.ulid()}`;
++ orders.set(id, { id, subject, item_id: `itd_${ids.ulid()}`, name: 'Rainbow', currency, amount }); return id;
++ };
++ const pay = (id, subject = alice.id) => spender.spend(subject, 42, `Bearer ${token}`, id);
++ await check('concurrent Vibes payment retries debit once and cannot exceed available funds', async () => {
++ const id = order('vibes', 60);
++ const [a, b] = await Promise.all([pay(id), pay(id)]);
++ assert.strictEqual(a.payment_id, b.payment_id);
++ assert.strictEqual((await t.balances(alice.id)).credit, 40);
++ await assert.rejects(pay(order('vibes', 60)), (e) => e.status === 402 || e.code === 'billing.insufficient_funds');
++ assert.strictEqual((await t.balances(alice.id)).credit, 40);
++ await assert.rejects(pay(id, bob.id), (e) => e.status === 404);
++ });
++ await check('Network uncertain response retries the exact same debit key and amount', async () => {
++ const id = order('coins', 30);
++ loseResponse = true;
++ await assert.rejects(pay(id), (e) => e.code === 'billing.payment_unknown');
++ const result = await pay(id);
++ assert.strictEqual(result.amount, 30);
++ assert.strictEqual(debited.size, 1);
++ assert.strictEqual((await pay(id)).payment_id, result.payment_id);
++ });
++ await check('untrusted or foreign order snapshots cannot move money', async () => {
++ await assert.rejects(pay(order('vibes', 1, bob.id)), (e) => e.code === 'billing.invalid_purchase');
++ await assert.rejects(pay(order('vibes', -1)), (e) => e.code === 'billing.invalid_purchase');
++ await assert.rejects(pay('https://evil.example'), (e) => e.status === 422);
++ });
++ await check('bearer Vibes checkout fixes subject and price and replays its provider session', async () => {
++ const body = { provider: 'stripe', vibes: 100, idempotency_key: 'checkout-test-01', amount_cents: 1, subject: bob.id };
++ const a = await t.call('POST', '/api/v1/me/vibes/buy', { token, body });
++ assert.strictEqual(a.status, 200, a.text);
++ assert.strictEqual(a.json.intent.subject.id, alice.id);
++ assert.strictEqual(a.json.intent.amount_cents, t.ctx.rates.priceCents(100));
++ const before = t.stripe.calls.length;
++ const b = await t.call('POST', '/api/v1/me/vibes/buy', { token, body });
++ assert.deepStrictEqual(b.json, a.json); assert.strictEqual(t.stripe.calls.length, before);
++ const c = await t.call('POST', '/api/v1/me/vibes/buy', { token, body: { ...body, vibes: 200 } });
++ assert.strictEqual(c.status, 409);
++ });
++ await check('top-up allows only configured amounts and always funds the authenticated person', async () => {
++ const r = await t.call('POST', '/api/v1/me/wallet/topups', { token,
++ body: { provider: 'stripe', amount_cents: 1000, idempotency_key: 'checkout-topup-01', subject: bob.id, project_id: 'prj_bad' } });
++ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json.intent.subject.id, alice.id);
++ assert.strictEqual(r.json.intent.project_id, null);
++ });
++ await check('the wallet names each checkout provider and its hosted checkout host', async () => {
++ const { offers } = require('../server/ops/wallet-checkout');
++ const providers = offers(t.ctx, t.app.locals.adapters).providers;
++ const stripe = providers.find((p) => p.name === 'stripe');
++ assert.deepStrictEqual(stripe, { name: 'stripe', hosts: ['checkout.stripe.com'] }, 'the sheet may follow only this host');
++ assert(providers.every((p) => Array.isArray(p.hosts)), 'every provider names its hosts');
++ });
++ await check('an uncertain provider response leaves one durable claim, never a second checkout', async () => {
++ let calls = 0;
++ const adapters = { stripe: { name: 'stripe', enabled: true, createCheckout: async () => { calls++; throw new Error('lost reply'); } } };
++ const body = { provider: 'stripe', vibes: 100, idempotency_key: 'uncertain-checkout' };
++ await assert.rejects(checkout(t.ctx, adapters, alice.id, 'vibes', body));
++ await assert.rejects(checkout(t.ctx, adapters, alice.id, 'vibes', body), (e) => e.code === 'billing.checkout_pending');
++ assert.strictEqual(calls, 1);
++ });
++ await check('the Live owner switch also refuses store charges and checkouts', async () => {
++ t.network.state.moneyFrozen = true;
++ await assert.rejects(pay(order('vibes', 1)), (e) => e.code === 'billing.frozen');
++ await assert.rejects(checkout(t.ctx, {}, alice.id, 'vibes', { provider: 'stripe', vibes: 100, idempotency_key: 'owner-freeze-test' }), (e) => e.code === 'billing.frozen');
++ t.network.state.moneyFrozen = false;
++ });
++ await check('freeze refuses new store charges and checkouts', async () => {
++ await t.call('POST', '/api/v1/admin/freeze', { body: { on: true, reason: 'wallet test' } });
++ const before = debited.size;
++ await assert.rejects(pay(order('coins', 5)), (e) => e.code === 'billing.frozen');
++ assert.strictEqual(debited.size, before);
++ await assert.rejects(checkout(t.ctx, {}, alice.id, 'vibes', { provider: 'stripe', vibes: 100, idempotency_key: 'frozen-checkout' }), (e) => e.code === 'billing.frozen');
++ });
++ await t.assertReconciled();
++ await t.close(); done();
++})();
ddiff --git a/test/wallet-return.test.js b/test/wallet-return.test.js
nnew file mode 100644
iindex 0000000..4558f27
---- /dev/null
++++ b/test/wallet-return.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { startProviderStub } = require('./helpers/stubs');
++
++(async () => {
++ for (const kind of ['vibes', 'topup']) {
++ const stub = await startProviderStub();
++ const t = await boot({ env: { BILLING_AUTHORITY: 'billing', PAYPAL_CLIENT_ID: 'pp', PAYPAL_CLIENT_SECRET: 'pps',
++ PAYPAL_WEBHOOK_ID: 'WH-1', PAYPAL_API_BASE: stub.url, BILLING_CREDITS: '1', BILLING_CREDITS_TOPUP: '1',
++ BILLING_CREDITS_TOPUP_AMOUNTS_CENTS: '1000', BILLING_CREDITS_TOPUP_FEE_BPS: '0' } });
++ try {
++ await check(`${kind}: PayPal return captures the saved intent once and honors the owner switch`, async () => {
++ const buyer = t.user(410), token = t.network.signUser({ subject_id: buyer.id });
++ const start = await t.call('POST', kind === 'vibes' ? '/api/v1/me/vibes/buy' : '/api/v1/me/wallet/topups', {
++ token, body: { provider: 'paypal', idempotency_key: 'paypal-return-01', ...(kind === 'vibes' ? { vibes: 1000 } : { amount_cents: 1000 }) },
++ });
++ assert.strictEqual(start.status, 200, start.text);
++ const go = (token) => fetch(`${t.base}/checkout/success?token=${token}`, { redirect: 'manual' });
++ assert.strictEqual((await go('unknown-order')).status, 404);
++ t.network.state.moneyFrozen = true;
++ assert.strictEqual((await go('PPORDER1')).status, 503);
++ assert.strictEqual(stub.calls.filter((c) => c.url.endsWith('/capture')).length, 0);
++ t.network.state.moneyFrozen = false;
++ const result = await go('PPORDER1');
++ assert.strictEqual(result.status, 303);
++ assert.strictEqual(result.headers.get('location'), 'https://openvibe.network/wallet');
++ assert.strictEqual((await go('PPORDER1')).status, 303);
++ assert.strictEqual(stub.calls.filter((c) => c.url.endsWith('/capture')).length, 1);
++ const intent = await t.db.prepare('SELECT status, settled_txn FROM payment_intents WHERE id = ?').get(start.json.intent.id);
++ assert.strictEqual(intent.status, 'settled'); assert(intent.settled_txn);
++ if (kind === 'vibes') assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ await t.assertReconciled('wallet PayPal return');
++ });
++ } finally { await t.close(); await stub.close(); }
++ }
++ done();
++})();
ddiff --git a/test/wallet.test.js b/test/wallet.test.js
nnew file mode 100644
iindex 0000000..516d97b
---- /dev/null
++++ b/test/wallet.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ env: { BILLING_AUTHORITY: 'billing' } });
++ const alice = t.user(301), bob = t.user(302);
++ const token = t.network.signUser({ subject_id: alice.id });
++ let coins = 0, history = [], down = false, requests = 0;
++ t.ctx.fetchImpl = async (url, opts) => {
++ if (url.endsWith('/api/payments/wallet-state')) return { ok: true, json: async () => ({ money_writes_frozen: false }) };
++ requests++;
++ assert.strictEqual(opts.headers.Authorization, `Bearer ${token}`);
++ assert.strictEqual(opts.credentials, 'omit');
++ assert.strictEqual(opts.redirect, 'error');
++ assert(!url.includes('with=vibes'), 'must not loop from Network back into Billing');
++ assert(url.startsWith(t.config.network.internalUrl + '/api/coins/me'));
++ if (down) throw new Error('Network offline');
++ return { ok: true, json: async () => url.includes('/history') ? { transactions: history } : { balance: coins } };
++ };
++ const read = (auth = token, headers = {}) => t.call('GET', '/api/v1/me/wallet', { token: auth, headers });
++ await fund(t, bob.id, 900);
++ await check('one wallet read includes all three zero balances, currency, purpose and actions', async () => {
++ const r = await read();
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json.subject, alice);
++ for (const b of Object.values(r.json.balances)) {
++ assert.strictEqual(b.amount, '0'); assert(b.currency && b.purpose && b.action);
++ }
++ assert.strictEqual(r.json.currency, 'USD');
++ assert.strictEqual(r.json.used_ai_this_month, false);
++ assert.deepStrictEqual(r.json.recent_activity, []);
++ assert.match(r.headers.get('cache-control'), /private, no-store/);
++ });
++ await check('own balances and newest five events merge Network and Billing with site attribution', async () => {
++ coins = 45;
++ await fund(t, alice.id, 120);
++ history = Array.from({ length: 5 }, (_, i) => ({ id: i, delta: 9, app_id: 'quest', reason: 'Daily quest', created_at: new Date(t.ctx.now() - (i + 1) * 60000).toISOString() }));
++ const r = await read();
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.balances.coins.amount, '45');
++ assert.strictEqual(r.json.balances.vibes.amount, '120');
++ assert.strictEqual(r.json.recent_activity.length, 5);
++ assert.strictEqual(r.json.recent_activity[0].kind, 'vibes');
++ assert.strictEqual(r.json.recent_activity[1].site, 'quest');
++ });
++ await check('no cookies, forged tokens, service tokens or subject overrides can read another wallet', async () => {
++ const before = requests;
++ assert.strictEqual((await read(null, { Cookie: 'ov_token=unused' })).status, 401);
++ assert.strictEqual((await read(t.network.signService({ cap: ['billing.*'] }))).status, 401);
++ assert.strictEqual((await read(token + 'bad')).status, 401);
++ assert.strictEqual(requests, before);
++ const r = await t.call('GET', `/api/v1/me/wallet?subject=${bob.id}`, { token });
++ assert.strictEqual(r.json.subject.id, alice.id);
++ });
++ await check('first-party bearer CORS, no credentialed cookies or tenant origins', async () => {
++ const r = await read(token, { Origin: 'https://openvibe.live' });
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.live');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
++ const bad = await read(token, { Origin: 'https://tenant.openvibe.website' });
++ assert.strictEqual(bad.headers.get('access-control-allow-origin'), null);
++ const pre = await t.call('OPTIONS', '/api/v1/me/wallet', { token: null, headers: { Origin: 'https://openvibe.live' } });
++ assert.strictEqual(pre.status, 204);
++ assert.match(pre.headers.get('access-control-allow-headers'), /Authorization/);
++ });
++ await check('upstream outage or invalid amount never becomes a false zero', async () => {
++ down = true;
++ assert.strictEqual((await read()).status, 502);
++ down = false; coins = null;
++ assert.strictEqual((await read()).status, 502);
++ coins = 45;
++ });
++ await check('freeze preserves reads and shadow ledger is refused', async () => {
++ await t.call('POST', '/api/v1/admin/freeze', { body: { on: true, reason: 'wallet test' } });
++ const r = await read();
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.money_writes_frozen, true);
++ t.config.authority = 'live';
++ assert.strictEqual((await read()).status, 409);
++ });
++ await t.close(); done();
++})();