pay-lobster 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +401 -0
- package/README.md.bak +401 -0
- package/dist/agent.d.ts +132 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +224 -0
- package/dist/agent.js.map +1 -0
- package/dist/analytics.d.ts +120 -0
- package/dist/analytics.d.ts.map +1 -0
- package/dist/analytics.js +345 -0
- package/dist/analytics.js.map +1 -0
- package/dist/approvals.d.ts +168 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +406 -0
- package/dist/approvals.js.map +1 -0
- package/dist/circle-client.d.ts +152 -0
- package/dist/circle-client.d.ts.map +1 -0
- package/dist/circle-client.js +266 -0
- package/dist/circle-client.js.map +1 -0
- package/dist/commission.d.ts +191 -0
- package/dist/commission.d.ts.map +1 -0
- package/dist/commission.js +475 -0
- package/dist/commission.js.map +1 -0
- package/dist/condition-builder.d.ts +98 -0
- package/dist/condition-builder.d.ts.map +1 -0
- package/dist/condition-builder.js +193 -0
- package/dist/condition-builder.js.map +1 -0
- package/dist/contacts.d.ts +179 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +445 -0
- package/dist/contacts.js.map +1 -0
- package/dist/easy.d.ts +22 -0
- package/dist/easy.d.ts.map +1 -0
- package/dist/easy.js +40 -0
- package/dist/easy.js.map +1 -0
- package/dist/erc8004/constants.d.ts +152 -0
- package/dist/erc8004/constants.d.ts.map +1 -0
- package/dist/erc8004/constants.js +114 -0
- package/dist/erc8004/constants.js.map +1 -0
- package/dist/erc8004/discovery.d.ts +84 -0
- package/dist/erc8004/discovery.d.ts.map +1 -0
- package/dist/erc8004/discovery.js +217 -0
- package/dist/erc8004/discovery.js.map +1 -0
- package/dist/erc8004/identity.d.ts +91 -0
- package/dist/erc8004/identity.d.ts.map +1 -0
- package/dist/erc8004/identity.js +250 -0
- package/dist/erc8004/identity.js.map +1 -0
- package/dist/erc8004/index.d.ts +147 -0
- package/dist/erc8004/index.d.ts.map +1 -0
- package/dist/erc8004/index.js +225 -0
- package/dist/erc8004/index.js.map +1 -0
- package/dist/erc8004/reputation.d.ts +133 -0
- package/dist/erc8004/reputation.d.ts.map +1 -0
- package/dist/erc8004/reputation.js +277 -0
- package/dist/erc8004/reputation.js.map +1 -0
- package/dist/escrow-templates.d.ts +38 -0
- package/dist/escrow-templates.d.ts.map +1 -0
- package/dist/escrow-templates.js +419 -0
- package/dist/escrow-templates.js.map +1 -0
- package/dist/escrow.d.ts +320 -0
- package/dist/escrow.d.ts.map +1 -0
- package/dist/escrow.js +854 -0
- package/dist/escrow.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/invoices.d.ts +212 -0
- package/dist/invoices.d.ts.map +1 -0
- package/dist/invoices.js +393 -0
- package/dist/invoices.js.map +1 -0
- package/dist/notifications.d.ts +141 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +350 -0
- package/dist/notifications.js.map +1 -0
- package/dist/tips.d.ts +171 -0
- package/dist/tips.d.ts.map +1 -0
- package/dist/tips.js +390 -0
- package/dist/tips.js.map +1 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/x402-client.d.ts +127 -0
- package/dist/x402-client.d.ts.map +1 -0
- package/dist/x402-client.js +350 -0
- package/dist/x402-client.js.map +1 -0
- package/dist/x402-server.d.ts +133 -0
- package/dist/x402-server.d.ts.map +1 -0
- package/dist/x402-server.js +330 -0
- package/dist/x402-server.js.map +1 -0
- package/lib/agent.ts +273 -0
- package/lib/analytics.ts +474 -0
- package/lib/analytics.ts.bak +474 -0
- package/lib/approvals.ts +585 -0
- package/lib/approvals.ts.bak +585 -0
- package/lib/circle-client.ts +376 -0
- package/lib/circle-client.ts.bak +376 -0
- package/lib/commission.ts +680 -0
- package/lib/commission.ts.bak +680 -0
- package/lib/condition-builder.ts +223 -0
- package/lib/condition-builder.ts.bak +223 -0
- package/lib/contacts.ts +615 -0
- package/lib/contacts.ts.bak +615 -0
- package/lib/easy.ts +46 -0
- package/lib/easy.ts.bak +352 -0
- package/lib/erc8004/constants.ts +175 -0
- package/lib/erc8004/discovery.ts +299 -0
- package/lib/erc8004/identity.ts +327 -0
- package/lib/erc8004/index.ts +285 -0
- package/lib/erc8004/reputation.ts +368 -0
- package/lib/escrow-templates.ts +462 -0
- package/lib/escrow.ts +1216 -0
- package/lib/index.ts +13 -0
- package/lib/invoices.ts +588 -0
- package/lib/notifications.ts +484 -0
- package/lib/tips.ts +570 -0
- package/lib/types.ts +108 -0
- package/lib/x402-client.ts +471 -0
- package/lib/x402-server.ts +462 -0
- package/package.json +58 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commission Splitter Module
|
|
3
|
+
*
|
|
4
|
+
* Auto-distribute USDC commissions between multiple parties:
|
|
5
|
+
* agents, brokers, referral partners, team members.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
export interface SplitRecipient {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
role: 'listing_agent' | 'buyer_agent' | 'broker' | 'referral' | 'team_member' | 'company' | 'custom';
|
|
16
|
+
walletAddress: string;
|
|
17
|
+
|
|
18
|
+
// Split calculation
|
|
19
|
+
splitType: 'percentage' | 'fixed';
|
|
20
|
+
splitValue: string; // e.g., "70" for 70% or "500" for $500 fixed
|
|
21
|
+
|
|
22
|
+
// Caps and minimums
|
|
23
|
+
minAmount?: string;
|
|
24
|
+
maxAmount?: string;
|
|
25
|
+
|
|
26
|
+
// For tiered splits (e.g., broker gets more after agent hits cap)
|
|
27
|
+
tier?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommissionSplit {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
|
|
35
|
+
// Transaction details
|
|
36
|
+
propertyAddress?: string;
|
|
37
|
+
mlsNumber?: string;
|
|
38
|
+
closingDate?: string;
|
|
39
|
+
salePrice?: string;
|
|
40
|
+
|
|
41
|
+
// Commission
|
|
42
|
+
totalCommission: string;
|
|
43
|
+
chain: string;
|
|
44
|
+
sourceWalletId: string;
|
|
45
|
+
|
|
46
|
+
// Recipients and their splits
|
|
47
|
+
recipients: SplitRecipient[];
|
|
48
|
+
|
|
49
|
+
// Calculated payouts
|
|
50
|
+
payouts: {
|
|
51
|
+
recipientId: string;
|
|
52
|
+
amount: string;
|
|
53
|
+
status: 'pending' | 'processing' | 'sent' | 'failed';
|
|
54
|
+
txHash?: string;
|
|
55
|
+
sentAt?: string;
|
|
56
|
+
error?: string;
|
|
57
|
+
}[];
|
|
58
|
+
|
|
59
|
+
// Overall status
|
|
60
|
+
status: 'draft' | 'ready' | 'processing' | 'completed' | 'partial' | 'failed';
|
|
61
|
+
|
|
62
|
+
// Execution
|
|
63
|
+
executeAt?: string; // Schedule for future
|
|
64
|
+
executedAt?: string;
|
|
65
|
+
|
|
66
|
+
// Metadata
|
|
67
|
+
notes?: string;
|
|
68
|
+
createdAt: string;
|
|
69
|
+
updatedAt: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SplitTemplate {
|
|
73
|
+
id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
|
|
77
|
+
// Template recipients (without specific addresses)
|
|
78
|
+
recipients: {
|
|
79
|
+
role: SplitRecipient['role'];
|
|
80
|
+
splitType: SplitRecipient['splitType'];
|
|
81
|
+
splitValue: string;
|
|
82
|
+
tier?: number;
|
|
83
|
+
}[];
|
|
84
|
+
|
|
85
|
+
// Usage tracking
|
|
86
|
+
usageCount: number;
|
|
87
|
+
lastUsedAt?: string;
|
|
88
|
+
|
|
89
|
+
createdAt: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const DATA_DIR = process.env.USDC_DATA_DIR || './data';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Commission Splitter
|
|
96
|
+
*/
|
|
97
|
+
export class CommissionSplitter {
|
|
98
|
+
private splitsPath: string;
|
|
99
|
+
private templatesPath: string;
|
|
100
|
+
|
|
101
|
+
constructor(dataDir = DATA_DIR) {
|
|
102
|
+
this.splitsPath = path.join(dataDir, 'commission-splits.json');
|
|
103
|
+
this.templatesPath = path.join(dataDir, 'split-templates.json');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async loadSplits(): Promise<CommissionSplit[]> {
|
|
107
|
+
try {
|
|
108
|
+
const data = await fs.readFile(this.splitsPath, 'utf-8');
|
|
109
|
+
return JSON.parse(data);
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async saveSplits(splits: CommissionSplit[]): Promise<void> {
|
|
116
|
+
await fs.mkdir(path.dirname(this.splitsPath), { recursive: true });
|
|
117
|
+
await fs.writeFile(this.splitsPath, JSON.stringify(splits, null, 2));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async loadTemplates(): Promise<SplitTemplate[]> {
|
|
121
|
+
try {
|
|
122
|
+
const data = await fs.readFile(this.templatesPath, 'utf-8');
|
|
123
|
+
return JSON.parse(data);
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async saveTemplates(templates: SplitTemplate[]): Promise<void> {
|
|
130
|
+
await fs.mkdir(path.dirname(this.templatesPath), { recursive: true });
|
|
131
|
+
await fs.writeFile(this.templatesPath, JSON.stringify(templates, null, 2));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============ Split Creation ============
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create a commission split
|
|
138
|
+
*/
|
|
139
|
+
async createSplit(params: {
|
|
140
|
+
name: string;
|
|
141
|
+
totalCommission: string;
|
|
142
|
+
chain: string;
|
|
143
|
+
sourceWalletId: string;
|
|
144
|
+
recipients: Omit<SplitRecipient, 'id'>[];
|
|
145
|
+
propertyAddress?: string;
|
|
146
|
+
mlsNumber?: string;
|
|
147
|
+
closingDate?: string;
|
|
148
|
+
salePrice?: string;
|
|
149
|
+
executeAt?: string;
|
|
150
|
+
notes?: string;
|
|
151
|
+
}): Promise<CommissionSplit> {
|
|
152
|
+
const splits = await this.loadSplits();
|
|
153
|
+
|
|
154
|
+
// Validate splits add up correctly
|
|
155
|
+
const validation = this.validateSplits(params.totalCommission, params.recipients as SplitRecipient[]);
|
|
156
|
+
if (!validation.valid) {
|
|
157
|
+
throw new Error(validation.error);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const split: CommissionSplit = {
|
|
161
|
+
id: `CS-${Date.now().toString(36).toUpperCase()}`,
|
|
162
|
+
name: params.name,
|
|
163
|
+
totalCommission: params.totalCommission,
|
|
164
|
+
chain: params.chain,
|
|
165
|
+
sourceWalletId: params.sourceWalletId,
|
|
166
|
+
propertyAddress: params.propertyAddress,
|
|
167
|
+
mlsNumber: params.mlsNumber,
|
|
168
|
+
closingDate: params.closingDate,
|
|
169
|
+
salePrice: params.salePrice,
|
|
170
|
+
recipients: params.recipients.map(r => ({
|
|
171
|
+
...r,
|
|
172
|
+
id: crypto.randomUUID(),
|
|
173
|
+
})),
|
|
174
|
+
payouts: [],
|
|
175
|
+
status: 'draft',
|
|
176
|
+
executeAt: params.executeAt,
|
|
177
|
+
notes: params.notes,
|
|
178
|
+
createdAt: new Date().toISOString(),
|
|
179
|
+
updatedAt: new Date().toISOString(),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Calculate payouts
|
|
183
|
+
split.payouts = this.calculatePayouts(split);
|
|
184
|
+
split.status = 'ready';
|
|
185
|
+
|
|
186
|
+
splits.push(split);
|
|
187
|
+
await this.saveSplits(splits);
|
|
188
|
+
|
|
189
|
+
return split;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create split from template
|
|
194
|
+
*/
|
|
195
|
+
async createFromTemplate(
|
|
196
|
+
templateId: string,
|
|
197
|
+
params: {
|
|
198
|
+
totalCommission: string;
|
|
199
|
+
chain: string;
|
|
200
|
+
sourceWalletId: string;
|
|
201
|
+
recipientAddresses: { role: string; name: string; address: string }[];
|
|
202
|
+
propertyAddress?: string;
|
|
203
|
+
closingDate?: string;
|
|
204
|
+
salePrice?: string;
|
|
205
|
+
}
|
|
206
|
+
): Promise<CommissionSplit> {
|
|
207
|
+
const templates = await this.loadTemplates();
|
|
208
|
+
const template = templates.find(t => t.id === templateId);
|
|
209
|
+
|
|
210
|
+
if (!template) {
|
|
211
|
+
throw new Error('Template not found');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Map template roles to actual recipients
|
|
215
|
+
const recipients: Omit<SplitRecipient, 'id'>[] = template.recipients.map(tr => {
|
|
216
|
+
const actual = params.recipientAddresses.find(ra => ra.role === tr.role);
|
|
217
|
+
if (!actual) {
|
|
218
|
+
throw new Error(`No address provided for role: ${tr.role}`);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
name: actual.name,
|
|
222
|
+
role: tr.role,
|
|
223
|
+
walletAddress: actual.address,
|
|
224
|
+
splitType: tr.splitType,
|
|
225
|
+
splitValue: tr.splitValue,
|
|
226
|
+
tier: tr.tier,
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Update template usage
|
|
231
|
+
template.usageCount++;
|
|
232
|
+
template.lastUsedAt = new Date().toISOString();
|
|
233
|
+
await this.saveTemplates(templates);
|
|
234
|
+
|
|
235
|
+
return this.createSplit({
|
|
236
|
+
name: `${template.name} - ${params.propertyAddress || new Date().toLocaleDateString()}`,
|
|
237
|
+
totalCommission: params.totalCommission,
|
|
238
|
+
chain: params.chain,
|
|
239
|
+
sourceWalletId: params.sourceWalletId,
|
|
240
|
+
recipients,
|
|
241
|
+
propertyAddress: params.propertyAddress,
|
|
242
|
+
closingDate: params.closingDate,
|
|
243
|
+
salePrice: params.salePrice,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Quick split for common scenarios
|
|
249
|
+
*/
|
|
250
|
+
async quickSplit(params: {
|
|
251
|
+
totalCommission: string;
|
|
252
|
+
chain: string;
|
|
253
|
+
sourceWalletId: string;
|
|
254
|
+
scenario: 'listing_side' | 'buyer_side' | 'both_sides' | 'referral';
|
|
255
|
+
agent: { name: string; address: string; split?: string };
|
|
256
|
+
broker: { name: string; address: string; split?: string };
|
|
257
|
+
referral?: { name: string; address: string; split?: string };
|
|
258
|
+
propertyAddress?: string;
|
|
259
|
+
}): Promise<CommissionSplit> {
|
|
260
|
+
const recipients: Omit<SplitRecipient, 'id'>[] = [];
|
|
261
|
+
|
|
262
|
+
// Default splits based on scenario
|
|
263
|
+
const defaultSplits = {
|
|
264
|
+
listing_side: { agent: '70', broker: '30' },
|
|
265
|
+
buyer_side: { agent: '70', broker: '30' },
|
|
266
|
+
both_sides: { agent: '70', broker: '30' },
|
|
267
|
+
referral: { agent: '50', broker: '25', referral: '25' },
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const splits = defaultSplits[params.scenario];
|
|
271
|
+
|
|
272
|
+
// Agent
|
|
273
|
+
recipients.push({
|
|
274
|
+
name: params.agent.name,
|
|
275
|
+
role: params.scenario === 'buyer_side' ? 'buyer_agent' : 'listing_agent',
|
|
276
|
+
walletAddress: params.agent.address,
|
|
277
|
+
splitType: 'percentage',
|
|
278
|
+
splitValue: params.agent.split || splits.agent,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Broker
|
|
282
|
+
recipients.push({
|
|
283
|
+
name: params.broker.name,
|
|
284
|
+
role: 'broker',
|
|
285
|
+
walletAddress: params.broker.address,
|
|
286
|
+
splitType: 'percentage',
|
|
287
|
+
splitValue: params.broker.split || splits.broker,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Referral (if applicable)
|
|
291
|
+
if (params.referral && params.scenario === 'referral') {
|
|
292
|
+
recipients.push({
|
|
293
|
+
name: params.referral.name,
|
|
294
|
+
role: 'referral',
|
|
295
|
+
walletAddress: params.referral.address,
|
|
296
|
+
splitType: 'percentage',
|
|
297
|
+
splitValue: params.referral.split || splits.referral || '25',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return this.createSplit({
|
|
302
|
+
name: `Commission - ${params.propertyAddress || 'Quick Split'}`,
|
|
303
|
+
totalCommission: params.totalCommission,
|
|
304
|
+
chain: params.chain,
|
|
305
|
+
sourceWalletId: params.sourceWalletId,
|
|
306
|
+
recipients,
|
|
307
|
+
propertyAddress: params.propertyAddress,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============ Payout Calculation ============
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Calculate payouts for all recipients
|
|
315
|
+
*/
|
|
316
|
+
private calculatePayouts(split: CommissionSplit): CommissionSplit['payouts'] {
|
|
317
|
+
const total = parseFloat(split.totalCommission);
|
|
318
|
+
const payouts: CommissionSplit['payouts'] = [];
|
|
319
|
+
let remaining = total;
|
|
320
|
+
|
|
321
|
+
// Sort by tier (lower tier = paid first)
|
|
322
|
+
const sortedRecipients = [...split.recipients].sort((a, b) =>
|
|
323
|
+
(a.tier || 0) - (b.tier || 0)
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
for (const recipient of sortedRecipients) {
|
|
327
|
+
let amount: number;
|
|
328
|
+
|
|
329
|
+
if (recipient.splitType === 'percentage') {
|
|
330
|
+
amount = total * (parseFloat(recipient.splitValue) / 100);
|
|
331
|
+
} else {
|
|
332
|
+
amount = parseFloat(recipient.splitValue);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Apply min/max caps
|
|
336
|
+
if (recipient.minAmount && amount < parseFloat(recipient.minAmount)) {
|
|
337
|
+
amount = parseFloat(recipient.minAmount);
|
|
338
|
+
}
|
|
339
|
+
if (recipient.maxAmount && amount > parseFloat(recipient.maxAmount)) {
|
|
340
|
+
amount = parseFloat(recipient.maxAmount);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Don't exceed remaining
|
|
344
|
+
if (amount > remaining) {
|
|
345
|
+
amount = remaining;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
remaining -= amount;
|
|
349
|
+
|
|
350
|
+
payouts.push({
|
|
351
|
+
recipientId: recipient.id,
|
|
352
|
+
amount: amount.toFixed(2),
|
|
353
|
+
status: 'pending',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return payouts;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Validate splits add up correctly
|
|
362
|
+
*/
|
|
363
|
+
private validateSplits(
|
|
364
|
+
total: string,
|
|
365
|
+
recipients: SplitRecipient[]
|
|
366
|
+
): { valid: boolean; error?: string } {
|
|
367
|
+
const totalAmount = parseFloat(total);
|
|
368
|
+
let percentageSum = 0;
|
|
369
|
+
let fixedSum = 0;
|
|
370
|
+
|
|
371
|
+
for (const r of recipients) {
|
|
372
|
+
if (r.splitType === 'percentage') {
|
|
373
|
+
percentageSum += parseFloat(r.splitValue);
|
|
374
|
+
} else {
|
|
375
|
+
fixedSum += parseFloat(r.splitValue);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Check percentages
|
|
380
|
+
if (percentageSum > 100) {
|
|
381
|
+
return { valid: false, error: `Percentages sum to ${percentageSum}%, exceeds 100%` };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check fixed amounts
|
|
385
|
+
const percentageAmount = totalAmount * (percentageSum / 100);
|
|
386
|
+
if (fixedSum + percentageAmount > totalAmount) {
|
|
387
|
+
return { valid: false, error: 'Fixed amounts + percentages exceed total commission' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return { valid: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============ Execution ============
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Execute a split (send all payouts)
|
|
397
|
+
* Returns the split with updated payout statuses
|
|
398
|
+
*/
|
|
399
|
+
async execute(
|
|
400
|
+
splitId: string,
|
|
401
|
+
sendFn: (to: string, amount: string, chain: string) => Promise<{ txHash: string }>
|
|
402
|
+
): Promise<CommissionSplit | null> {
|
|
403
|
+
const splits = await this.loadSplits();
|
|
404
|
+
const split = splits.find(s => s.id === splitId);
|
|
405
|
+
|
|
406
|
+
if (!split || split.status !== 'ready') {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
split.status = 'processing';
|
|
411
|
+
await this.saveSplits(splits);
|
|
412
|
+
|
|
413
|
+
let successCount = 0;
|
|
414
|
+
let failCount = 0;
|
|
415
|
+
|
|
416
|
+
for (const payout of split.payouts) {
|
|
417
|
+
const recipient = split.recipients.find(r => r.id === payout.recipientId);
|
|
418
|
+
if (!recipient) continue;
|
|
419
|
+
|
|
420
|
+
payout.status = 'processing';
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const result = await sendFn(recipient.walletAddress, payout.amount, split.chain);
|
|
424
|
+
payout.status = 'sent';
|
|
425
|
+
payout.txHash = result.txHash;
|
|
426
|
+
payout.sentAt = new Date().toISOString();
|
|
427
|
+
successCount++;
|
|
428
|
+
} catch (err: any) {
|
|
429
|
+
payout.status = 'failed';
|
|
430
|
+
payout.error = err.message;
|
|
431
|
+
failCount++;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await this.saveSplits(splits);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Update overall status
|
|
438
|
+
if (failCount === 0) {
|
|
439
|
+
split.status = 'completed';
|
|
440
|
+
} else if (successCount === 0) {
|
|
441
|
+
split.status = 'failed';
|
|
442
|
+
} else {
|
|
443
|
+
split.status = 'partial';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
split.executedAt = new Date().toISOString();
|
|
447
|
+
split.updatedAt = new Date().toISOString();
|
|
448
|
+
await this.saveSplits(splits);
|
|
449
|
+
|
|
450
|
+
return split;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Retry failed payouts
|
|
455
|
+
*/
|
|
456
|
+
async retryFailed(
|
|
457
|
+
splitId: string,
|
|
458
|
+
sendFn: (to: string, amount: string, chain: string) => Promise<{ txHash: string }>
|
|
459
|
+
): Promise<CommissionSplit | null> {
|
|
460
|
+
const splits = await this.loadSplits();
|
|
461
|
+
const split = splits.find(s => s.id === splitId);
|
|
462
|
+
|
|
463
|
+
if (!split) return null;
|
|
464
|
+
|
|
465
|
+
const failedPayouts = split.payouts.filter(p => p.status === 'failed');
|
|
466
|
+
|
|
467
|
+
for (const payout of failedPayouts) {
|
|
468
|
+
const recipient = split.recipients.find(r => r.id === payout.recipientId);
|
|
469
|
+
if (!recipient) continue;
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = await sendFn(recipient.walletAddress, payout.amount, split.chain);
|
|
473
|
+
payout.status = 'sent';
|
|
474
|
+
payout.txHash = result.txHash;
|
|
475
|
+
payout.sentAt = new Date().toISOString();
|
|
476
|
+
payout.error = undefined;
|
|
477
|
+
} catch (err: any) {
|
|
478
|
+
payout.error = err.message;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Recalculate status
|
|
483
|
+
const statuses = split.payouts.map(p => p.status);
|
|
484
|
+
if (statuses.every(s => s === 'sent')) {
|
|
485
|
+
split.status = 'completed';
|
|
486
|
+
} else if (statuses.some(s => s === 'sent')) {
|
|
487
|
+
split.status = 'partial';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
split.updatedAt = new Date().toISOString();
|
|
491
|
+
await this.saveSplits(splits);
|
|
492
|
+
|
|
493
|
+
return split;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ============ Templates ============
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Create a split template
|
|
500
|
+
*/
|
|
501
|
+
async createTemplate(params: {
|
|
502
|
+
name: string;
|
|
503
|
+
description?: string;
|
|
504
|
+
recipients: SplitTemplate['recipients'];
|
|
505
|
+
}): Promise<SplitTemplate> {
|
|
506
|
+
const templates = await this.loadTemplates();
|
|
507
|
+
|
|
508
|
+
const template: SplitTemplate = {
|
|
509
|
+
id: crypto.randomUUID(),
|
|
510
|
+
name: params.name,
|
|
511
|
+
description: params.description,
|
|
512
|
+
recipients: params.recipients,
|
|
513
|
+
usageCount: 0,
|
|
514
|
+
createdAt: new Date().toISOString(),
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
templates.push(template);
|
|
518
|
+
await this.saveTemplates(templates);
|
|
519
|
+
|
|
520
|
+
return template;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* List templates
|
|
525
|
+
*/
|
|
526
|
+
async listTemplates(): Promise<SplitTemplate[]> {
|
|
527
|
+
return this.loadTemplates();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Get common RE templates
|
|
532
|
+
*/
|
|
533
|
+
async getDefaultTemplates(): Promise<SplitTemplate[]> {
|
|
534
|
+
return [
|
|
535
|
+
{
|
|
536
|
+
id: 'default-70-30',
|
|
537
|
+
name: 'Standard 70/30 Split',
|
|
538
|
+
description: 'Agent gets 70%, broker gets 30%',
|
|
539
|
+
recipients: [
|
|
540
|
+
{ role: 'listing_agent', splitType: 'percentage', splitValue: '70' },
|
|
541
|
+
{ role: 'broker', splitType: 'percentage', splitValue: '30' },
|
|
542
|
+
],
|
|
543
|
+
usageCount: 0,
|
|
544
|
+
createdAt: new Date().toISOString(),
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
id: 'default-80-20',
|
|
548
|
+
name: 'Senior Agent 80/20',
|
|
549
|
+
description: 'Senior agent split - 80% agent, 20% broker',
|
|
550
|
+
recipients: [
|
|
551
|
+
{ role: 'listing_agent', splitType: 'percentage', splitValue: '80' },
|
|
552
|
+
{ role: 'broker', splitType: 'percentage', splitValue: '20' },
|
|
553
|
+
],
|
|
554
|
+
usageCount: 0,
|
|
555
|
+
createdAt: new Date().toISOString(),
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
id: 'default-referral',
|
|
559
|
+
name: 'Referral Split',
|
|
560
|
+
description: '25% to referral agent, remaining split 70/30',
|
|
561
|
+
recipients: [
|
|
562
|
+
{ role: 'referral', splitType: 'percentage', splitValue: '25', tier: 1 },
|
|
563
|
+
{ role: 'listing_agent', splitType: 'percentage', splitValue: '52.5', tier: 2 },
|
|
564
|
+
{ role: 'broker', splitType: 'percentage', splitValue: '22.5', tier: 2 },
|
|
565
|
+
],
|
|
566
|
+
usageCount: 0,
|
|
567
|
+
createdAt: new Date().toISOString(),
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
id: 'default-team',
|
|
571
|
+
name: 'Team Split',
|
|
572
|
+
description: 'Team lead, agent, broker, team fee',
|
|
573
|
+
recipients: [
|
|
574
|
+
{ role: 'company', splitType: 'fixed', splitValue: '500', tier: 1 }, // Team fee
|
|
575
|
+
{ role: 'listing_agent', splitType: 'percentage', splitValue: '50', tier: 2 },
|
|
576
|
+
{ role: 'team_member', splitType: 'percentage', splitValue: '20', tier: 2 },
|
|
577
|
+
{ role: 'broker', splitType: 'percentage', splitValue: '30', tier: 2 },
|
|
578
|
+
],
|
|
579
|
+
usageCount: 0,
|
|
580
|
+
createdAt: new Date().toISOString(),
|
|
581
|
+
},
|
|
582
|
+
];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ============ Queries ============
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Get split by ID
|
|
589
|
+
*/
|
|
590
|
+
async get(id: string): Promise<CommissionSplit | null> {
|
|
591
|
+
const splits = await this.loadSplits();
|
|
592
|
+
return splits.find(s => s.id === id) || null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* List splits with filters
|
|
597
|
+
*/
|
|
598
|
+
async list(filters?: {
|
|
599
|
+
status?: CommissionSplit['status'];
|
|
600
|
+
recipientAddress?: string;
|
|
601
|
+
fromDate?: string;
|
|
602
|
+
toDate?: string;
|
|
603
|
+
}): Promise<CommissionSplit[]> {
|
|
604
|
+
let splits = await this.loadSplits();
|
|
605
|
+
|
|
606
|
+
if (filters?.status) {
|
|
607
|
+
splits = splits.filter(s => s.status === filters.status);
|
|
608
|
+
}
|
|
609
|
+
if (filters?.recipientAddress) {
|
|
610
|
+
splits = splits.filter(s =>
|
|
611
|
+
s.recipients.some(r =>
|
|
612
|
+
r.walletAddress.toLowerCase() === filters.recipientAddress!.toLowerCase()
|
|
613
|
+
)
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (filters?.fromDate) {
|
|
617
|
+
splits = splits.filter(s => s.createdAt >= filters.fromDate!);
|
|
618
|
+
}
|
|
619
|
+
if (filters?.toDate) {
|
|
620
|
+
splits = splits.filter(s => s.createdAt <= filters.toDate!);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return splits.sort((a, b) =>
|
|
624
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get pending splits (ready but not executed)
|
|
630
|
+
*/
|
|
631
|
+
async getPending(): Promise<CommissionSplit[]> {
|
|
632
|
+
return this.list({ status: 'ready' });
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Get splits due for execution
|
|
637
|
+
*/
|
|
638
|
+
async getDue(): Promise<CommissionSplit[]> {
|
|
639
|
+
const splits = await this.list({ status: 'ready' });
|
|
640
|
+
const now = new Date().toISOString();
|
|
641
|
+
|
|
642
|
+
return splits.filter(s => !s.executeAt || s.executeAt <= now);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ============ Formatting ============
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Format split summary
|
|
649
|
+
*/
|
|
650
|
+
formatSplitSummary(split: CommissionSplit): string {
|
|
651
|
+
const statusEmoji = {
|
|
652
|
+
draft: '📝',
|
|
653
|
+
ready: '✅',
|
|
654
|
+
processing: '⏳',
|
|
655
|
+
completed: '✓',
|
|
656
|
+
partial: '⚠️',
|
|
657
|
+
failed: '❌',
|
|
658
|
+
}[split.status];
|
|
659
|
+
|
|
660
|
+
let summary = `${statusEmoji} **Commission Split ${split.id}**\n\n`;
|
|
661
|
+
|
|
662
|
+
if (split.propertyAddress) {
|
|
663
|
+
summary += `📍 ${split.propertyAddress}\n`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
summary += `💵 Total: **$${split.totalCommission} USDC**\n`;
|
|
667
|
+
summary += `Status: ${split.status.toUpperCase()}\n\n`;
|
|
668
|
+
|
|
669
|
+
summary += `**Payouts:**\n`;
|
|
670
|
+
for (const payout of split.payouts) {
|
|
671
|
+
const recipient = split.recipients.find(r => r.id === payout.recipientId);
|
|
672
|
+
const payoutEmoji = payout.status === 'sent' ? '✓' : payout.status === 'failed' ? '✗' : '○';
|
|
673
|
+
summary += `${payoutEmoji} ${recipient?.name} (${recipient?.role}): $${payout.amount}\n`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return summary;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export default CommissionSplitter;
|