diff --git a/web/drizzle/0008_add_dividends.sql b/web/drizzle/0008_add_dividends.sql new file mode 100644 index 0000000..937bb42 --- /dev/null +++ b/web/drizzle/0008_add_dividends.sql @@ -0,0 +1,32 @@ +-- Add dividend distribution history table so Patrons can track the +-- history of revenue shared out to Mitos/Pulse token holders. +CREATE TABLE IF NOT EXISTS "tls_dividends" ( + "id" text PRIMARY KEY NOT NULL, + "talosId" text NOT NULL REFERENCES "tls_talos"("id") ON DELETE CASCADE, + "amount" numeric(18, 6) NOT NULL, + "currency" text DEFAULT 'USDC' NOT NULL, + "patronCount" integer DEFAULT 0 NOT NULL, + "totalPulse" integer DEFAULT 0 NOT NULL, + "source" text DEFAULT 'revenue-share' NOT NULL, + "txHash" text, + "breakdown" jsonb, + "status" text DEFAULT 'completed' NOT NULL, + "createdAt" timestamp(3) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "tls_dividends_talosId_createdAt_idx" + ON "tls_dividends"("talosId", "createdAt"); +--> statement-breakpoint +-- Row Level Security (consistent with all other tables in this schema) +ALTER TABLE "tls_dividends" ENABLE ROW LEVEL SECURITY; +--> statement-breakpoint +-- Dividend history is public read (Patrons & visitors can view distributions) +CREATE POLICY "anon_read_dividends" ON "tls_dividends" + FOR SELECT TO anon USING (true); +--> statement-breakpoint +CREATE POLICY "auth_read_dividends" ON "tls_dividends" + FOR SELECT TO authenticated USING (true); +--> statement-breakpoint +-- The server (postgres role) performs all writes +CREATE POLICY "postgres_all_dividends" ON "tls_dividends" + FOR ALL TO postgres USING (true) WITH CHECK (true); diff --git a/web/src/app/api/talos/[id]/buy-token/route.ts b/web/src/app/api/talos/[id]/buy-token/route.ts index 2130041..544b175 100644 --- a/web/src/app/api/talos/[id]/buy-token/route.ts +++ b/web/src/app/api/talos/[id]/buy-token/route.ts @@ -84,10 +84,17 @@ export async function POST( const usdcIssuer = getUSDCIssuer(); const usdcAsset = new Asset("USDC", usdcIssuer); - const tx = TransactionBuilder.fromXDR(txResult.envelope_xdr, networkPassphrase); + const tx = TransactionBuilder.fromXDR(txResult.envelope_xdr, networkPassphrase); + + // `fromXDR` may return a FeeBumpTransaction (which has no `source`); in + // that case the relevant source is on the wrapped inner transaction. + const innerTx = "innerTransaction" in tx ? tx.innerTransaction : tx; // Validate: source_account == buyerPublicKey - if (tx.source !== buyerPublicKey && txResult.source_account !== buyerPublicKey) { + if ( + innerTx.source !== buyerPublicKey && + txResult.source_account !== buyerPublicKey + ) { return NextResponse.json( { error: "Transaction signer does not match buyerPublicKey" }, { status: 400 }, @@ -95,7 +102,7 @@ export async function POST( } // Validate at least one operation is a USDC payment of the correct amount to the treasury - const ops = tx.operations as unknown as Array<{ + const ops = innerTx.operations as unknown as Array<{ type: string; asset?: { code: string; issuer: string }; destination?: string; diff --git a/web/src/app/api/talos/[id]/dividends/route.ts b/web/src/app/api/talos/[id]/dividends/route.ts new file mode 100644 index 0000000..90cb089 --- /dev/null +++ b/web/src/app/api/talos/[id]/dividends/route.ts @@ -0,0 +1,120 @@ +import { NextRequest } from "next/server"; +import { db } from "@/db"; +import { tlsTalos, tlsDividends } from "@/db/schema"; +import { desc, eq } from "drizzle-orm"; +import { verifyAgentApiKey } from "@/lib/auth"; +import { recordDividendSchema, parseBody } from "@/lib/schemas"; + +/** + * GET /api/talos/:id/dividends + * + * List dividend distribution history for a TALOS so Patrons can track the + * revenue that has been shared out to Mitos/Pulse token holders over time. + * + * Public read (consistent with revenue history + RLS anon_read policy). + * Returns the most recent 50 distributions, newest first. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const talos = await db + .select({ id: tlsTalos.id }) + .from(tlsTalos) + .where(eq(tlsTalos.id, id)) + .limit(1) + .then((r) => r[0] ?? null); + + if (!talos) { + return Response.json({ error: "TALOS not found" }, { status: 404 }); + } + + const dividends = await db + .select() + .from(tlsDividends) + .where(eq(tlsDividends.talosId, id)) + .orderBy(desc(tlsDividends.createdAt)) + .limit(50); + + return Response.json(dividends); + } catch { + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} + +/** + * POST /api/talos/:id/dividends + * + * Record a new dividend distribution event in the database. + * + * This is a privileged financial write (the agent/operator records that a + * distribution to patrons occurred), so it requires the TALOS API key via + * `Authorization: Bearer ` — consistent with POST /revenue. + * + * Body: + * amount — total distributed (string or positive number) [required] + * currency — default "USDC" + * patronCount — number of patrons paid in this event + * totalPulse — total Mitos/Pulse held by recipients at distribution time + * source — e.g. "revenue-share" | "manual" + * txHash — optional on-chain settlement reference + * breakdown — optional per-patron array + * status — "completed" | "pending" | "failed" + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const auth = await verifyAgentApiKey(request, id); + if (!auth.ok) return auth.response; + + const parsed = await parseBody(request, recordDividendSchema); + if (parsed.error) return parsed.error; + + const { + amount, + currency, + patronCount, + totalPulse, + source, + txHash, + breakdown, + status, + } = parsed.data; + + // Normalize amount to a string for the numeric column and guard against + // non-positive / non-finite values that Zod's union may let through. + const amountNum = typeof amount === "number" ? amount : parseFloat(amount); + if (!Number.isFinite(amountNum) || amountNum <= 0) { + return Response.json( + { error: "amount must be a positive number" }, + { status: 400 }, + ); + } + + const [dividend] = await db + .insert(tlsDividends) + .values({ + talosId: id, + amount: String(amount), + currency: currency ?? "USDC", + patronCount: patronCount ?? 0, + totalPulse: totalPulse ?? 0, + source: source ?? "revenue-share", + txHash: txHash ?? null, + breakdown: breakdown ?? null, + status: status ?? "completed", + }) + .returning(); + + return Response.json(dividend, { status: 201 }); + } catch { + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/web/src/app/api/talos/[id]/revenue/distribute/route.ts b/web/src/app/api/talos/[id]/revenue/distribute/route.ts index 686021b..548b409 100644 --- a/web/src/app/api/talos/[id]/revenue/distribute/route.ts +++ b/web/src/app/api/talos/[id]/revenue/distribute/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; import { db } from "@/db"; -import { tlsTalos, tlsPatrons, tlsRevenues } from "@/db/schema"; +import { tlsTalos, tlsPatrons, tlsRevenues, tlsDividends } from "@/db/schema"; import { eq, and, sum } from "drizzle-orm"; import { OPERATOR_PUBLIC_KEY, USDC_ISSUER } from "@/lib/stellar-config"; @@ -115,8 +115,37 @@ export async function POST( } } + // Persist a dividend distribution history record so Patrons can track + // distributions over time via GET /api/talos/:id/dividends. Only record + // when at least one transfer succeeded. Best-effort: a logging failure + // must not fail the distribution that already settled on-chain. + const distributedTotal = transfers.reduce((s, t) => s + t.amount, 0); + let dividendId: string | null = null; + if (transfers.length > 0 && distributedTotal > 0) { + try { + const [dividend] = await db + .insert(tlsDividends) + .values({ + talosId: id, + amount: distributedTotal.toFixed(6), + currency: "USDC", + patronCount: transfers.length, + totalPulse, + source: "revenue-share", + txHash: transfers[0]?.txHash ?? null, + breakdown: transfers, + status: errors.length > 0 ? "partial" : "completed", + }) + .returning({ id: tlsDividends.id }); + dividendId = dividend?.id ?? null; + } catch (logErr) { + console.error("[revenue/distribute] failed to record dividend history", logErr); + } + } + return Response.json({ success: true, + dividendId, totalRevenue, distributableAmount, investorSharePercent: investorShare, diff --git a/web/src/db/relations.ts b/web/src/db/relations.ts index 5749a1a..cd7693b 100644 --- a/web/src/db/relations.ts +++ b/web/src/db/relations.ts @@ -5,6 +5,7 @@ import { tlsActivities, tlsApprovals, tlsRevenues, + tlsDividends, tlsCommerceServices, tlsCommerceJobs, tlsPlaybooks, @@ -17,6 +18,7 @@ export const talosRelations = relations(tlsTalos, ({ many, one }) => ({ activities: many(tlsActivities), approvals: many(tlsApprovals), revenues: many(tlsRevenues), + dividends: many(tlsDividends), commerceServices: one(tlsCommerceServices), commerceJobs: many(tlsCommerceJobs), playbooks: many(tlsPlaybooks), @@ -39,6 +41,10 @@ export const revenueRelations = relations(tlsRevenues, ({ one }) => ({ talos: one(tlsTalos, { fields: [tlsRevenues.talosId], references: [tlsTalos.id] }), })); +export const dividendRelations = relations(tlsDividends, ({ one }) => ({ + talos: one(tlsTalos, { fields: [tlsDividends.talosId], references: [tlsTalos.id] }), +})); + export const commerceServiceRelations = relations(tlsCommerceServices, ({ one }) => ({ talos: one(tlsTalos, { fields: [tlsCommerceServices.talosId], references: [tlsTalos.id] }), })); diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts index 2cccd00..6304672 100644 --- a/web/src/db/schema.ts +++ b/web/src/db/schema.ts @@ -152,6 +152,50 @@ export const tlsRevenues = pgTable( ], ); +// ─── Dividend Distribution (Patron Revenue Share History) ───────── +// +// Records each dividend distribution event so Patrons can track the +// history of revenue shared out to Mitos/Pulse token holders. One row +// per distribution event (a single POST /revenue/distribute run or a +// manually recorded distribution). + +export const tlsDividends = pgTable( + "tls_dividends", + { + id: text("id").primaryKey().$defaultFn(() => createId()), + talosId: text("talosId").notNull().references(() => tlsTalos.id, { onDelete: "cascade" }), + + // Total USDC (or other currency) distributed to patrons in this event + amount: numeric("amount", { precision: 18, scale: 6 }).notNull(), + currency: text("currency").notNull().default("USDC"), + + // Number of patrons that received a payout in this event + patronCount: integer("patronCount").notNull().default(0), + + // Total Mitos/Pulse outstanding among recipients at distribution time — + // lets the UI reconstruct per-share payout without re-querying balances. + totalPulse: integer("totalPulse").notNull().default(0), + + // Where the distribution came from: "revenue-share" (auto from treasury), + // "manual" (recorded by creator/operator), etc. + source: text("source").notNull().default("revenue-share"), + + // Optional reference to the on-chain settlement (or first tx of a batch) + txHash: text("txHash"), + + // Optional structured per-patron breakdown of the distribution + // (e.g. [{ stellarPublicKey, pulseAmount, amount, txHash }]) + breakdown: jsonb("breakdown"), + + status: text("status").notNull().default("completed"), + + createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(), + }, + (t) => [ + index("tls_dividends_talosId_createdAt_idx").on(t.talosId, t.createdAt), + ], +); + // ─── Commerce Service (Storefront) ──────────────────────────────── export const tlsCommerceServices = pgTable( diff --git a/web/src/lib/openapi.ts b/web/src/lib/openapi.ts index 46abf9b..5a8d9ca 100644 --- a/web/src/lib/openapi.ts +++ b/web/src/lib/openapi.ts @@ -328,6 +328,61 @@ Inter-agent commerce uses the Stellar x402 payment protocol: txHash: { type: "string", nullable: true }, }, }, + Dividend: { + type: "object", + properties: { + id: { type: "string" }, + talosId: { type: "string" }, + amount: { type: "string", example: "25.500000", description: "Total distributed to patrons in this event" }, + currency: { type: "string", example: "USDC" }, + patronCount: { type: "integer", example: 4, description: "Number of patrons paid in this event" }, + totalPulse: { type: "integer", example: 100000, description: "Total Mitos/Pulse held by recipients at distribution time" }, + source: { type: "string", example: "revenue-share", description: "revenue-share | manual" }, + txHash: { type: "string", nullable: true }, + breakdown: { + type: "array", + nullable: true, + description: "Optional per-patron breakdown of the distribution", + items: { + type: "object", + properties: { + stellarPublicKey: { type: "string" }, + pulseAmount: { type: "integer" }, + amount: { type: "string" }, + txHash: { type: "string", nullable: true }, + }, + }, + }, + status: { type: "string", example: "completed", enum: ["completed", "pending", "failed", "partial"] }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + RecordDividendRequest: { + type: "object", + required: ["amount"], + properties: { + amount: { type: "string", minLength: 1, example: "25.50", description: "Total distributed amount (string or positive number)" }, + currency: { type: "string", default: "USDC", maxLength: 20 }, + patronCount: { type: "integer", minimum: 0, default: 0 }, + totalPulse: { type: "integer", minimum: 0, default: 0 }, + source: { type: "string", maxLength: 50, default: "revenue-share" }, + txHash: { type: "string", nullable: true }, + breakdown: { + type: "array", + items: { + type: "object", + required: ["stellarPublicKey"], + properties: { + stellarPublicKey: { type: "string" }, + pulseAmount: { type: "integer", minimum: 0 }, + amount: { type: "string" }, + txHash: { type: "string", nullable: true }, + }, + }, + }, + status: { type: "string", enum: ["completed", "pending", "failed"], default: "completed" }, + }, + }, UpdateStatusRequest: { type: "object", required: ["agentOnline"], @@ -1453,6 +1508,65 @@ Creators are registered automatically at TALOS genesis.`, }, }, }, + "/api/talos/{id}/dividends": { + get: { + tags: ["Revenue"], + summary: "List dividend distribution history", + description: "Returns the last 50 dividend distributions for a TALOS, newest first, so Patrons can track revenue shared out to Mitos/Pulse holders. Public read.", + operationId: "listDividends", + parameters: [{ $ref: "#/components/parameters/talosId" }], + responses: { + "200": { + description: "Dividend distribution list", + content: { + "application/json": { + schema: { type: "array", items: { $ref: "#/components/schemas/Dividend" } }, + }, + }, + }, + "404": { $ref: "#/components/responses/NotFoundError" }, + "500": { $ref: "#/components/responses/InternalError" }, + }, + }, + post: { + tags: ["Revenue"], + summary: "Record a dividend distribution", + description: "Record a new dividend distribution event in the database. Requires Bearer auth (agent/operator).", + operationId: "recordDividend", + security: [{ BearerAuth: [] }], + parameters: [{ $ref: "#/components/parameters/talosId" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/RecordDividendRequest" }, + example: { + amount: "25.50", + currency: "USDC", + patronCount: 4, + totalPulse: 100000, + source: "revenue-share", + txHash: "abc123...", + }, + }, + }, + }, + responses: { + "201": { + description: "Dividend distribution recorded", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Dividend" }, + }, + }, + }, + "400": { description: "Validation failed or non-positive amount" }, + "401": { $ref: "#/components/responses/UnauthorizedError" }, + "403": { $ref: "#/components/responses/ForbiddenError" }, + "500": { $ref: "#/components/responses/InternalError" }, + }, + }, + }, "/api/talos/{id}/revenue/buyback": { get: { tags: ["Revenue"], diff --git a/web/src/lib/schemas.ts b/web/src/lib/schemas.ts index ee6a2ad..2fc4336 100644 --- a/web/src/lib/schemas.ts +++ b/web/src/lib/schemas.ts @@ -113,6 +113,27 @@ export const reportRevenueSchema = z.object({ txHash: z.string().nullable().optional(), }); +// --- Dividends (Patron distribution history) --- + +const dividendBreakdownEntrySchema = z.object({ + stellarPublicKey: z.string().min(1), + pulseAmount: z.number().int().nonnegative().optional(), + amount: z.union([z.string(), z.number()]).optional(), + txHash: z.string().nullable().optional(), +}); + +export const recordDividendSchema = z.object({ + // Total distributed amount. Accept string (numeric column) or number. + amount: z.union([z.string().min(1), z.number().positive()]), + currency: z.string().max(20).optional().default("USDC"), + patronCount: z.number().int().nonnegative().optional().default(0), + totalPulse: z.number().int().nonnegative().optional().default(0), + source: z.string().max(50).optional().default("revenue-share"), + txHash: z.string().nullable().optional(), + breakdown: z.array(dividendBreakdownEntrySchema).optional(), + status: z.enum(["completed", "pending", "failed"]).optional().default("completed"), +}); + // --- Status --- export const updateStatusSchema = z.object({ diff --git a/web/tests/buy-token.test.ts b/web/tests/buy-token.test.ts index 67de4eb..b82c7ea 100644 --- a/web/tests/buy-token.test.ts +++ b/web/tests/buy-token.test.ts @@ -66,8 +66,8 @@ vi.mock("@/db", () => { vi.mock("@/lib/stellar", () => { return { getAccountInfo: (...args: any[]) => mocks.mockGetAccountInfo(...args), - getNetworkPassphrase: (...args: any[]) => mocks.mockGetNetworkPassphrase(...args), - getUSDCIssuer: (...args: any[]) => mocks.mockGetUSDCIssuer(...args), + getNetworkPassphrase: () => mocks.mockGetNetworkPassphrase(), + getUSDCIssuer: () => mocks.mockGetUSDCIssuer(), }; }); diff --git a/web/tests/dividends.test.ts b/web/tests/dividends.test.ts new file mode 100644 index 0000000..abb9abd --- /dev/null +++ b/web/tests/dividends.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import type { tlsDividends } from "@/db/schema"; + +// ── Test double types ───────────────────────────────────────────── +// The route is called as `GET(req, ctx)` / `POST(req, ctx)` where `ctx` +// carries the dynamic-segment params. Mirror that contract here instead +// of casting the call sites to `any`. +type RouteContext = { params: Promise<{ id: string }> }; + +// Shape inserted by `db.insert(tlsDividends).values(...)`. We derive it +// from the schema so the mock stays in sync with the real insert type. +type DividendInsert = typeof tlsDividends.$inferInsert; +type DividendRow = DividendInsert & { id: string }; + +// Chainable thenable the route awaits. Modelling it explicitly lets the +// `db.select()` mock stay fully typed (no `any` on the builder). +interface SelectBuilder extends PromiseLike { + from: () => SelectBuilder; + where: () => SelectBuilder; + orderBy: () => SelectBuilder; + limit: (n?: number) => SelectBuilder | Promise; +} + +// ── Mock the database layer ─────────────────────────────────────── +// We model the two query shapes the route uses: +// GET: db.select().from().where().limit().then() (talos lookup) +// db.select().from().where().orderBy().limit() (list dividends) +// POST: db.insert().values().returning() (record dividend) +const state = { + talosExists: true, + dividends: [] as unknown[], + inserted: null as DividendRow | null, +}; + +function selectBuilder(): SelectBuilder { + // Chainable thenable that resolves to either the talos row or the list, + // depending on which terminal method is awaited. + const builder: SelectBuilder = { + from: () => builder, + where: () => builder, + orderBy: () => builder, + // GET list path: `await db.select()....limit(50)` returns the array + // directly; the talos-lookup path uses `.limit(1).then(...)` instead. + limit: (n?: number) => + n === 50 ? Promise.resolve(state.dividends) : builder, + // GET talos-lookup path: `.limit(1).then((r) => r[0] ?? null)` + then: ( + onfulfilled?: + | ((value: unknown[]) => TResult1 | PromiseLike) + | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | null, + ) => + Promise.resolve( + state.talosExists ? [{ id: "talos_1" }] : [], + ).then(onfulfilled, onrejected), + }; + return builder; +} + +vi.mock("@/db", () => ({ + db: { + select: () => selectBuilder(), + insert: () => ({ + values: (v: DividendInsert) => ({ + returning: () => { + state.inserted = { id: "div_1", ...v }; + return Promise.resolve([state.inserted]); + }, + }), + }), + }, +})); + +// Mock auth: API key "good-key" passes, anything else fails. +vi.mock("@/lib/auth", () => ({ + verifyAgentApiKey: vi.fn(async (req: NextRequest) => { + const h = req.headers.get("authorization"); + if (h === "Bearer good-key") { + return { ok: true, talos: { id: "talos_1", apiKey: "good-key" } }; + } + return { + ok: false, + response: Response.json({ error: "Invalid API key" }, { status: 403 }), + }; + }), +})); + +import { GET, POST } from "@/app/api/talos/[id]/dividends/route"; + +const params = Promise.resolve({ id: "talos_1" }); + +// GET ignores its request argument, but the signature still expects a +// NextRequest — provide a real (typed) one instead of casting to `any`. +function getReq(): NextRequest { + return new NextRequest("http://localhost/api/talos/talos_1/dividends"); +} + +function postReq(body: unknown, auth?: string): NextRequest { + return new NextRequest("http://localhost/api/talos/talos_1/dividends", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(auth ? { authorization: auth } : {}), + }, + body: JSON.stringify(body), + }); +} + +describe("GET /api/talos/[id]/dividends", () => { + beforeEach(() => { + state.talosExists = true; + state.dividends = [ + { id: "div_1", talosId: "talos_1", amount: "12.500000", patronCount: 3 }, + ]; + state.inserted = null; + }); + afterEach(() => vi.clearAllMocks()); + + it("returns the dividend history list", async () => { + const ctx: RouteContext = { params }; + const res = await GET(getReq(), ctx); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0].amount).toBe("12.500000"); + }); + + it("returns 404 when the TALOS does not exist", async () => { + state.talosExists = false; + const ctx: RouteContext = { params }; + const res = await GET(getReq(), ctx); + expect(res.status).toBe(404); + }); +}); + +describe("POST /api/talos/[id]/dividends", () => { + beforeEach(() => { + state.talosExists = true; + state.inserted = null; + }); + afterEach(() => vi.clearAllMocks()); + + it("rejects unauthenticated requests with 403", async () => { + const ctx: RouteContext = { params }; + const res = await POST(postReq({ amount: "10" }), ctx); + expect(res.status).toBe(403); + }); + + it("rejects an invalid body with 400", async () => { + const ctx: RouteContext = { params }; + const res = await POST(postReq({ currency: "USDC" }, "Bearer good-key"), ctx); + expect(res.status).toBe(400); + }); + + it("rejects a non-positive amount with 400", async () => { + const ctx: RouteContext = { params }; + const res = await POST(postReq({ amount: 0 }, "Bearer good-key"), ctx); + expect(res.status).toBe(400); + }); + + it("records a dividend distribution and returns 201", async () => { + const ctx: RouteContext = { params }; + const res = await POST( + postReq( + { + amount: "25.5", + patronCount: 4, + totalPulse: 100000, + source: "manual", + txHash: "abc123", + breakdown: [{ stellarPublicKey: "GABC", amount: "25.5" }], + }, + "Bearer good-key", + ), + ctx, + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).toBe("div_1"); + expect(body.talosId).toBe("talos_1"); + expect(body.amount).toBe("25.5"); + expect(body.patronCount).toBe(4); + expect(body.source).toBe("manual"); + expect(body.status).toBe("completed"); + }); + + it("defaults currency, source and status when omitted", async () => { + const ctx: RouteContext = { params }; + const res = await POST(postReq({ amount: "5" }, "Bearer good-key"), ctx); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.currency).toBe("USDC"); + expect(body.source).toBe("revenue-share"); + expect(body.status).toBe("completed"); + }); +}); diff --git a/web/tests/fixtures/openapi.snapshot.json b/web/tests/fixtures/openapi.snapshot.json index bd8bdea..af32fec 100644 --- a/web/tests/fixtures/openapi.snapshot.json +++ b/web/tests/fixtures/openapi.snapshot.json @@ -801,6 +801,154 @@ } } }, + "Dividend": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "talosId": { + "type": "string" + }, + "amount": { + "type": "string", + "example": "25.500000", + "description": "Total distributed to patrons in this event" + }, + "currency": { + "type": "string", + "example": "USDC" + }, + "patronCount": { + "type": "integer", + "example": 4, + "description": "Number of patrons paid in this event" + }, + "totalPulse": { + "type": "integer", + "example": 100000, + "description": "Total Mitos/Pulse held by recipients at distribution time" + }, + "source": { + "type": "string", + "example": "revenue-share", + "description": "revenue-share | manual" + }, + "txHash": { + "type": "string", + "nullable": true + }, + "breakdown": { + "type": "array", + "nullable": true, + "description": "Optional per-patron breakdown of the distribution", + "items": { + "type": "object", + "properties": { + "stellarPublicKey": { + "type": "string" + }, + "pulseAmount": { + "type": "integer" + }, + "amount": { + "type": "string" + }, + "txHash": { + "type": "string", + "nullable": true + } + } + } + }, + "status": { + "type": "string", + "example": "completed", + "enum": [ + "completed", + "pending", + "failed", + "partial" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "RecordDividendRequest": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "string", + "minLength": 1, + "example": "25.50", + "description": "Total distributed amount (string or positive number)" + }, + "currency": { + "type": "string", + "default": "USDC", + "maxLength": 20 + }, + "patronCount": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "totalPulse": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "source": { + "type": "string", + "maxLength": 50, + "default": "revenue-share" + }, + "txHash": { + "type": "string", + "nullable": true + }, + "breakdown": { + "type": "array", + "items": { + "type": "object", + "required": [ + "stellarPublicKey" + ], + "properties": { + "stellarPublicKey": { + "type": "string" + }, + "pulseAmount": { + "type": "integer", + "minimum": 0 + }, + "amount": { + "type": "string" + }, + "txHash": { + "type": "string", + "nullable": true + } + } + } + }, + "status": { + "type": "string", + "enum": [ + "completed", + "pending", + "failed" + ], + "default": "completed" + } + } + }, "UpdateStatusRequest": { "type": "object", "required": [ @@ -2797,6 +2945,102 @@ } } }, + "/api/talos/{id}/dividends": { + "get": { + "tags": [ + "Revenue" + ], + "summary": "List dividend distribution history", + "description": "Returns the last 50 dividend distributions for a TALOS, newest first, so Patrons can track revenue shared out to Mitos/Pulse holders. Public read.", + "operationId": "listDividends", + "parameters": [ + { + "$ref": "#/components/parameters/talosId" + } + ], + "responses": { + "200": { + "description": "Dividend distribution list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Dividend" + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFoundError" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "tags": [ + "Revenue" + ], + "summary": "Record a dividend distribution", + "description": "Record a new dividend distribution event in the database. Requires Bearer auth (agent/operator).", + "operationId": "recordDividend", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/talosId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordDividendRequest" + }, + "example": { + "amount": "25.50", + "currency": "USDC", + "patronCount": 4, + "totalPulse": 100000, + "source": "revenue-share", + "txHash": "abc123..." + } + } + } + }, + "responses": { + "201": { + "description": "Dividend distribution recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Dividend" + } + } + } + }, + "400": { + "description": "Validation failed or non-positive amount" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "403": { + "$ref": "#/components/responses/ForbiddenError" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/api/talos/{id}/revenue/buyback": { "get": { "tags": [