Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,52 @@ This is critical because PromptHash retries failed deliveries, which may result
Webhook endpoints should handle bursts gracefully. We recommend:
- Return 200 quickly and process asynchronously
- Use 429 with `Retry-After` header if your system is overloaded

## Payout Statements & Settlement Reconciliation Endpoints

For comprehensive settlement equations, carryover accounting, and export specifications, see [payout-statements.md](./payout-statements.md).

### List or Preview Payout Statements
`GET /api/payouts/statements/:walletAddress`

Query parameters:
- `preview`: `true` to preview reconciliation on-the-fly without database write.
- `periodStart`: Start date (ISO format).
- `periodEnd`: End date (ISO format).
- `page`: Page index (default: 1).
- `limit`: Page size (default: 20).

### Get Statement Details
`GET /api/payouts/statements/:walletAddress/:statementId`

Returns statement summary and itemized line items ledger.

### Export Statement
`GET /api/payouts/statements/:walletAddress/:statementId/export?format=csv|json`

Exports statement as RFC-4180 CSV or structured JSON.

### Generate Statement
`POST /api/payouts/statements/generate`

Body:
```json
{
"creatorWallet": "G...",
"periodStart": "2026-08-01T00:00:00.000Z",
"periodEnd": "2026-08-31T23:59:59.999Z",
"platformFeeBps": 500
}
```

### Update Statement Status
`PATCH /api/payouts/statements/:statementId/status`

Body:
```json
{
"status": "settled",
"payoutTxHash": "...",
"settledAt": "2026-09-01T00:00:00.000Z"
}
```
114 changes: 114 additions & 0 deletions docs/payout-statements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Settlement Reconciliation & Creator Payout Statements

## Overview

PromptHash implements transparent, verifiable accounting and settlement reconciliation for creators. Every prompt purchase, platform fee deduction, and refund is tracked item-by-item to produce balanced, exportable payout statements.

## Reconciliation Math & Balancing Invariants

Payout reconciliation obeys the following balancing formula:

$$\text{Net Settlement Amount} = \text{Gross Amount} - \text{Platform Fee Amount} - \text{Refund Amount} + \text{Previous Balance Carryover}$$

### Platform Fee Split
- Platform fee is fixed at **500 basis points (5.00%)**.
- Evaluated with 7 decimal places (Stellar stroop precision, $10^{-7}$ XLM).
- Formula:
$$\text{platformFeeAmount} = \frac{\text{grossAmount} \times 500}{10000}$$

### Refund vs. Clawback Classification
- **Same-Period Refund**: A refund for an item purchased during the current statement period window $[\text{periodStart}, \text{periodEnd}]$. The creator's net proceeds for that sale are subtracted.
- **Clawback**: A refund for an order purchased and paid out during a *prior* period. Because the creator already received the funds in a prior settlement, the refund is recorded as a clawback deduction in the current period.

### Negative Balance Carryover
- When refunds or clawbacks in a period exceed total gross earnings, the net settlement becomes negative ($\text{netSettlementAmount} < 0$).
- When negative:
- $\text{payableAmount} = 0$ (no funds are disbursed).
- $\text{closingBalanceCarryover} = \text{netSettlementAmount}$ (deficit is carried over into the next period).
- In the subsequent period, $\text{previousBalanceCarryover}$ is applied against new sales until the deficit is fully recovered.

## Settlement Status Lifecycle

Statements progress through three distinct states:

| Status | Description | Required Metadata |
|---|---|---|
| `pending` | Statement generated; awaiting on-chain batch disbursement | — |
| `settled` | Funds transferred to creator's Stellar wallet | `payoutTxHash`, `settledAt` |
| `failed` | Payment failed on Stellar network (e.g. unfunded trustline) | `failureReason` |

## Export Formats

Statements can be exported in two formats:

### 1. RFC-4180 Compliant CSV
The CSV export includes:
- Top metadata block (Statement ID, Creator Wallet, Date Range, Currency, Gross, Platform Fee, Refunds, Carryovers, Net Settlement, Payable Amount, Settlement Status, Payout Tx Hash, Settled Date).
- Blank line separator.
- Itemized transaction ledger table with column headers:
`Line Item ID, Date, Type, Prompt ID, Prompt Title, Buyer Wallet, Gross Amount, Fee Amount, Net Amount, Status, Tx Hash, Notes`
- RFC-4180 escaping: fields with quotes, commas, or newlines are enclosed in double quotes with `""` quote escapes and `\r\n` line endings.

### 2. Structured JSON
Full JSON payload containing both the aggregated settlement summary and the complete list of itemized line item records.

## API Endpoints

### List / Preview Statements
`GET /api/payouts/statements/:walletAddress`

Query Parameters:
- `preview`: `true` to calculate on-the-fly reconciliation for a custom date window without writing to DB.
- `periodStart`: ISO timestamp or `YYYY-MM-DD`.
- `periodEnd`: ISO timestamp or `YYYY-MM-DD`.
- `page`: Page number (default: 1).
- `limit`: Items per page (default: 20).

### Get Statement Details
`GET /api/payouts/statements/:walletAddress/:statementId`

Returns the statement document with full itemized line items.

### Export Statement
`GET /api/payouts/statements/:walletAddress/:statementId/export?format=csv|json`

- If `format=csv`: Returns `text/csv` with `Content-Disposition: attachment; filename="<statementId>.csv"`.
- If `format=json`: Returns `application/json` with `Content-Disposition: attachment; filename="<statementId>.json"`.

### Generate & Persist Statement
`POST /api/payouts/statements/generate`

Request Body:
```json
{
"creatorWallet": "G...",
"periodStart": "2026-08-01T00:00:00.000Z",
"periodEnd": "2026-08-31T23:59:59.999Z",
"platformFeeBps": 500
}
```

### Update Statement Status
`PATCH /api/payouts/statements/:statementId/status`

Request Body:
```json
{
"status": "settled",
"payoutTxHash": "3b29c9...",
"settledAt": "2026-09-01T12:00:00.000Z"
}
```
Or for failure:
```json
{
"status": "failed",
"failureReason": "destination_account_not_funded"
}
```

## Creator UI Integration

Creators can view and manage their payout statements in two locations:
1. **Payout Settings** (`/profile/payouts`): Full dedicated settlement reconciliation card with date filters, status badges, metric summaries, explorer transaction links, and CSV/JSON downloads.
2. **Profile Created Tab** (`/profile`): Embedded reconciliation card directly below the Creator Dashboard.
212 changes: 212 additions & 0 deletions server/src/models/PayoutStatement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import mongoose, { Document, Schema } from "mongoose";

export type PayoutStatus = "pending" | "settled" | "failed";
export type LineItemType = "sale" | "platform_fee" | "refund" | "clawback" | "carryover";
export type LineItemStatus = "pending" | "settled" | "failed" | "refunded" | "clawback";

export interface IPayoutLineItem {
lineItemId: string;
date: Date;
type: LineItemType;
promptId?: string;
promptTitle?: string;
buyerWallet?: string;
grossAmount: number;
feeAmount: number;
netAmount: number;
status: LineItemStatus;
txHash?: string;
notes?: string;
}

export interface IPayoutStatement extends Document {
statementId: string;
creatorWallet: string;
periodStart: Date;
periodEnd: Date;
grossAmount: number;
platformFeeAmount: number;
refundAmount: number;
previousBalanceCarryover: number;
netSettlementAmount: number;
closingBalanceCarryover: number;
payableAmount: number;
currency: string;
status: PayoutStatus;
payoutTxHash?: string | null;
failureReason?: string | null;
settledAt?: Date | null;
lineItems: IPayoutLineItem[];
createdAt: Date;
updatedAt: Date;
}

const PayoutLineItemSchema = new Schema<IPayoutLineItem>(
{
lineItemId: {
type: String,
required: true,
},
date: {
type: Date,
required: true,
default: Date.now,
},
type: {
type: String,
enum: ["sale", "platform_fee", "refund", "clawback", "carryover"],
required: true,
},
promptId: {
type: String,
default: null,
},
promptTitle: {
type: String,
default: null,
},
buyerWallet: {
type: String,
default: null,
lowercase: true,
trim: true,
},
grossAmount: {
type: Number,
required: true,
default: 0,
},
feeAmount: {
type: Number,
required: true,
default: 0,
},
netAmount: {
type: Number,
required: true,
default: 0,
},
status: {
type: String,
enum: ["pending", "settled", "failed", "refunded", "clawback"],
required: true,
default: "pending",
},
txHash: {
type: String,
default: null,
},
notes: {
type: String,
default: null,
},
},
{ _id: false }
);

const PayoutStatementSchema = new Schema<IPayoutStatement>(
{
statementId: {
type: String,
required: true,
unique: true,
index: true,
},
creatorWallet: {
type: String,
required: true,
lowercase: true,
trim: true,
index: true,
},
periodStart: {
type: Date,
required: true,
index: true,
},
periodEnd: {
type: Date,
required: true,
index: true,
},
grossAmount: {
type: Number,
required: true,
default: 0,
min: 0,
},
platformFeeAmount: {
type: Number,
required: true,
default: 0,
min: 0,
},
refundAmount: {
type: Number,
required: true,
default: 0,
min: 0,
},
previousBalanceCarryover: {
type: Number,
required: true,
default: 0,
},
netSettlementAmount: {
type: Number,
required: true,
default: 0,
},
closingBalanceCarryover: {
type: Number,
required: true,
default: 0,
},
payableAmount: {
type: Number,
required: true,
default: 0,
min: 0,
},
currency: {
type: String,
required: true,
default: "XLM",
},
status: {
type: String,
enum: ["pending", "settled", "failed"],
default: "pending",
index: true,
},
payoutTxHash: {
type: String,
default: null,
index: true,
},
failureReason: {
type: String,
default: null,
},
settledAt: {
type: Date,
default: null,
},
lineItems: {
type: [PayoutLineItemSchema],
default: [],
},
},
{
timestamps: true,
}
);

PayoutStatementSchema.index({ creatorWallet: 1, periodStart: -1 });
PayoutStatementSchema.index({ creatorWallet: 1, status: 1 });

const PayoutStatement =
mongoose.models.PayoutStatement ||
mongoose.model<IPayoutStatement>("PayoutStatement", PayoutStatementSchema);

export default PayoutStatement;
Loading