@bananasplitapp/cli 0.1.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/LICENSE +21 -0
- package/README.md +188 -0
- package/package.json +32 -0
- package/src/browser.ts +360 -0
- package/src/cli.ts +289 -0
- package/src/commands/balance.ts +128 -0
- package/src/commands/currencies.ts +49 -0
- package/src/commands/expenses.ts +543 -0
- package/src/commands/friends.ts +156 -0
- package/src/commands/groups.ts +627 -0
- package/src/commands/me.ts +45 -0
- package/src/commands/payments.ts +167 -0
- package/src/index.ts +11 -0
- package/src/request.ts +108 -0
- package/src/shared.ts +183 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_LIST_LIMIT,
|
|
3
|
+
appendQuery,
|
|
4
|
+
asArray,
|
|
5
|
+
asRecord,
|
|
6
|
+
cleanUserSummary,
|
|
7
|
+
currencyCode,
|
|
8
|
+
display,
|
|
9
|
+
encodedDetailId,
|
|
10
|
+
enumValue,
|
|
11
|
+
formatCard,
|
|
12
|
+
humanAmount,
|
|
13
|
+
namedEntity,
|
|
14
|
+
numeric,
|
|
15
|
+
parseJsonBody,
|
|
16
|
+
parseOptions,
|
|
17
|
+
positiveInteger,
|
|
18
|
+
repeatedStrings,
|
|
19
|
+
requiredString,
|
|
20
|
+
requirePositionals,
|
|
21
|
+
wantsHelp,
|
|
22
|
+
yesNo,
|
|
23
|
+
} from "../shared";
|
|
24
|
+
import { CliFailure, type ParsedCommand, type Presenter } from "../types";
|
|
25
|
+
|
|
26
|
+
const HELP = `Usage: banana groups <command>
|
|
27
|
+
|
|
28
|
+
Commands:
|
|
29
|
+
list [--limit N] [--cursor CURSOR] [--archived] [--sort balance|lastActivity]
|
|
30
|
+
create JSON
|
|
31
|
+
create --name TEXT --currency-id ID [--description TEXT]
|
|
32
|
+
[--type vacation|roommates|couple|travel|party|other]
|
|
33
|
+
[--member USER_ID]...
|
|
34
|
+
get <group-id>
|
|
35
|
+
members <group-id>
|
|
36
|
+
activities <group-id> [--search QUERY] [--limit N] [--page N]
|
|
37
|
+
[--type all|expenses|payments|recurring_expenses]
|
|
38
|
+
[--sort date|amount] [--direction asc|desc]`;
|
|
39
|
+
const LIST_HELP = `Usage: banana groups list [options]
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--limit N (default: ${DEFAULT_LIST_LIMIT})
|
|
43
|
+
--cursor CURSOR
|
|
44
|
+
--archived
|
|
45
|
+
--sort balance|lastActivity`;
|
|
46
|
+
const GET_HELP = "Usage: banana groups get <group-id>";
|
|
47
|
+
const MEMBERS_HELP = "Usage: banana groups members <group-id>";
|
|
48
|
+
const CREATE_HELP = `Usage: banana groups create JSON
|
|
49
|
+
or: banana groups create --name TEXT --currency-id ID [options]
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--description TEXT
|
|
53
|
+
--type vacation|roommates|couple|travel|party|other
|
|
54
|
+
--member USER_ID Repeat to add multiple members`;
|
|
55
|
+
const ACTIVITIES_HELP = `Usage: banana groups activities <group-id> [options]
|
|
56
|
+
|
|
57
|
+
Options:
|
|
58
|
+
--search QUERY
|
|
59
|
+
--limit N
|
|
60
|
+
--page N
|
|
61
|
+
--type all|expenses|payments|recurring_expenses
|
|
62
|
+
--sort date|amount
|
|
63
|
+
--direction asc|desc`;
|
|
64
|
+
|
|
65
|
+
function parseGroupsList(args: string[]): ParsedCommand {
|
|
66
|
+
if (wantsHelp(args)) return { kind: "help", text: LIST_HELP };
|
|
67
|
+
const { positionals, values } = parseOptions(args, {
|
|
68
|
+
archived: { type: "boolean" },
|
|
69
|
+
cursor: { type: "string" },
|
|
70
|
+
limit: { type: "string" },
|
|
71
|
+
sort: { type: "string" },
|
|
72
|
+
});
|
|
73
|
+
requirePositionals(positionals, 0, LIST_HELP);
|
|
74
|
+
|
|
75
|
+
const query = new URLSearchParams();
|
|
76
|
+
appendQuery(
|
|
77
|
+
query,
|
|
78
|
+
"l",
|
|
79
|
+
positiveInteger(values.limit, "--limit") ?? String(DEFAULT_LIST_LIMIT),
|
|
80
|
+
);
|
|
81
|
+
appendQuery(query, "cursor", values.cursor as string | undefined);
|
|
82
|
+
appendQuery(query, "archived", values.archived as boolean | undefined);
|
|
83
|
+
appendQuery(
|
|
84
|
+
query,
|
|
85
|
+
"sort",
|
|
86
|
+
enumValue(values.sort, "--sort", ["balance", "lastActivity"] as const),
|
|
87
|
+
);
|
|
88
|
+
return {
|
|
89
|
+
kind: "request",
|
|
90
|
+
path: "/groups",
|
|
91
|
+
presentation: "group-list",
|
|
92
|
+
query,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseGroupsCreate(args: string[]): ParsedCommand {
|
|
97
|
+
if (wantsHelp(args)) return { kind: "help", text: CREATE_HELP };
|
|
98
|
+
const { positionals, values } = parseOptions(args, {
|
|
99
|
+
"currency-id": { type: "string" },
|
|
100
|
+
description: { type: "string" },
|
|
101
|
+
member: { type: "string", multiple: true },
|
|
102
|
+
name: { type: "string" },
|
|
103
|
+
type: { type: "string" },
|
|
104
|
+
});
|
|
105
|
+
const jsonBody = parseJsonBody(positionals, values, CREATE_HELP);
|
|
106
|
+
if (jsonBody !== undefined) {
|
|
107
|
+
return {
|
|
108
|
+
kind: "request",
|
|
109
|
+
method: "POST",
|
|
110
|
+
path: "/groups",
|
|
111
|
+
presentation: "group-created",
|
|
112
|
+
body: jsonBody,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
requirePositionals(positionals, 0, CREATE_HELP);
|
|
116
|
+
|
|
117
|
+
const description = values.description as string | undefined;
|
|
118
|
+
const groupMembers = repeatedStrings(values.member);
|
|
119
|
+
const type = enumValue(values.type, "--type", [
|
|
120
|
+
"vacation",
|
|
121
|
+
"roommates",
|
|
122
|
+
"couple",
|
|
123
|
+
"travel",
|
|
124
|
+
"party",
|
|
125
|
+
"other",
|
|
126
|
+
] as const);
|
|
127
|
+
return {
|
|
128
|
+
kind: "request",
|
|
129
|
+
method: "POST",
|
|
130
|
+
path: "/groups",
|
|
131
|
+
presentation: "group-created",
|
|
132
|
+
body: {
|
|
133
|
+
name: requiredString(values.name, "--name", CREATE_HELP),
|
|
134
|
+
currencyId: requiredString(
|
|
135
|
+
values["currency-id"],
|
|
136
|
+
"--currency-id",
|
|
137
|
+
CREATE_HELP,
|
|
138
|
+
),
|
|
139
|
+
...(description === undefined ? {} : { description }),
|
|
140
|
+
...(type === undefined ? {} : { type }),
|
|
141
|
+
...(groupMembers.length === 0 ? {} : { groupMembers }),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseGroupsActivities(args: string[]): ParsedCommand {
|
|
147
|
+
if (wantsHelp(args)) return { kind: "help", text: ACTIVITIES_HELP };
|
|
148
|
+
const { positionals, values } = parseOptions(args, {
|
|
149
|
+
direction: { type: "string" },
|
|
150
|
+
limit: { type: "string" },
|
|
151
|
+
page: { type: "string" },
|
|
152
|
+
search: { type: "string" },
|
|
153
|
+
sort: { type: "string" },
|
|
154
|
+
type: { type: "string" },
|
|
155
|
+
});
|
|
156
|
+
requirePositionals(positionals, 1, ACTIVITIES_HELP);
|
|
157
|
+
|
|
158
|
+
const query = new URLSearchParams();
|
|
159
|
+
appendQuery(query, "l", positiveInteger(values.limit, "--limit"));
|
|
160
|
+
appendQuery(query, "p", positiveInteger(values.page, "--page"));
|
|
161
|
+
appendQuery(
|
|
162
|
+
query,
|
|
163
|
+
"type",
|
|
164
|
+
enumValue(values.type, "--type", [
|
|
165
|
+
"all",
|
|
166
|
+
"expenses",
|
|
167
|
+
"payments",
|
|
168
|
+
"recurring_expenses",
|
|
169
|
+
] as const),
|
|
170
|
+
);
|
|
171
|
+
appendQuery(
|
|
172
|
+
query,
|
|
173
|
+
"sort",
|
|
174
|
+
enumValue(values.sort, "--sort", ["date", "amount"] as const),
|
|
175
|
+
);
|
|
176
|
+
appendQuery(
|
|
177
|
+
query,
|
|
178
|
+
"direction",
|
|
179
|
+
enumValue(values.direction, "--direction", ["asc", "desc"] as const),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const groupId = encodeURIComponent(positionals[0]);
|
|
183
|
+
const search = values.search as string | undefined;
|
|
184
|
+
if (search !== undefined) query.set("q", search);
|
|
185
|
+
return {
|
|
186
|
+
kind: "request",
|
|
187
|
+
path: `/groups/${groupId}/activities${search === undefined ? "" : "/search"}`,
|
|
188
|
+
presentation: "activities",
|
|
189
|
+
query,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseGroups(args: string[]): ParsedCommand {
|
|
194
|
+
if (args.length === 0) return parseGroupsList(args);
|
|
195
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
196
|
+
return { kind: "help", text: HELP };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const [command, ...rest] = args;
|
|
200
|
+
if (command === "list") return parseGroupsList(rest);
|
|
201
|
+
if (command === "create") return parseGroupsCreate(rest);
|
|
202
|
+
if (command === "activities") return parseGroupsActivities(rest);
|
|
203
|
+
if (command === "get" || command === "members") {
|
|
204
|
+
const help = command === "get" ? GET_HELP : MEMBERS_HELP;
|
|
205
|
+
if (wantsHelp(rest)) return { kind: "help", text: help };
|
|
206
|
+
const { positionals } = parseOptions(rest);
|
|
207
|
+
requirePositionals(positionals, 1, help);
|
|
208
|
+
return {
|
|
209
|
+
kind: "request",
|
|
210
|
+
path: `/groups/${encodeURIComponent(positionals[0])}${
|
|
211
|
+
command === "members" ? "/members" : ""
|
|
212
|
+
}`,
|
|
213
|
+
presentation: command === "members" ? "members" : "group",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
throw new CliFailure("usage", HELP);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cleanGroupList(body: unknown) {
|
|
220
|
+
const response = asRecord(body);
|
|
221
|
+
return {
|
|
222
|
+
items: asArray(response.items).map((value) => {
|
|
223
|
+
const group = asRecord(value);
|
|
224
|
+
const members = asArray(group.groupMembers)
|
|
225
|
+
.map((member) => asRecord(member).name)
|
|
226
|
+
.filter((name): name is string => typeof name === "string");
|
|
227
|
+
return {
|
|
228
|
+
id: group.id ?? null,
|
|
229
|
+
name: group.name ?? null,
|
|
230
|
+
description: group.description ?? null,
|
|
231
|
+
type: group.type ?? null,
|
|
232
|
+
currency: currencyCode(group.currency),
|
|
233
|
+
balance: numeric(group.balance),
|
|
234
|
+
memberCount: members.length,
|
|
235
|
+
members,
|
|
236
|
+
mostRecentActivity: group.mostRecentActivity ?? null,
|
|
237
|
+
};
|
|
238
|
+
}),
|
|
239
|
+
hasMore: response.hasMore === true,
|
|
240
|
+
nextCursor: response.nextCursor ?? null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function cleanGroup(body: unknown) {
|
|
245
|
+
const group = asRecord(body);
|
|
246
|
+
return {
|
|
247
|
+
id: group.id ?? null,
|
|
248
|
+
name: group.name ?? null,
|
|
249
|
+
description: group.description ?? null,
|
|
250
|
+
type: group.type ?? null,
|
|
251
|
+
currency: currencyCode(group.currency),
|
|
252
|
+
balance: numeric(group.balance),
|
|
253
|
+
totalOwed: numeric(group.totalOwed),
|
|
254
|
+
totalOwing: numeric(group.totalOwing),
|
|
255
|
+
memberCount: numeric(group.memberCount),
|
|
256
|
+
defaultSplitType: group.defaultSplitType ?? null,
|
|
257
|
+
useOptimalSettlement: group.useOptimalSettlement === true,
|
|
258
|
+
memberBalanceVisibility: group.memberBalanceVisibility ?? null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanMembers(body: unknown) {
|
|
263
|
+
return asArray(body).map((value) => {
|
|
264
|
+
const member = asRecord(value);
|
|
265
|
+
return {
|
|
266
|
+
id: member.id ?? null,
|
|
267
|
+
userId: member.userId ?? null,
|
|
268
|
+
name: member.name ?? null,
|
|
269
|
+
role: member.role ?? null,
|
|
270
|
+
isGuest: member.isGuest === true,
|
|
271
|
+
isGold: member.isGold === true,
|
|
272
|
+
defaultSplitPercentage: numeric(member.defaultSplitPercentage),
|
|
273
|
+
joinedAt: member.joinedAt ?? null,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cleanActivities(body: unknown) {
|
|
279
|
+
return asArray(body).map((value) => {
|
|
280
|
+
const activity = asRecord(value);
|
|
281
|
+
if (activity.entity === "payment") {
|
|
282
|
+
return {
|
|
283
|
+
entity: "payment",
|
|
284
|
+
id: activity.id ?? null,
|
|
285
|
+
description: activity.description ?? null,
|
|
286
|
+
amount: numeric(activity.amount),
|
|
287
|
+
currency: currencyCode(activity.currency),
|
|
288
|
+
date: activity.date ?? null,
|
|
289
|
+
from: cleanUserSummary(activity.fromUser),
|
|
290
|
+
to: cleanUserSummary(activity.toUser),
|
|
291
|
+
isSettlement: activity.isSettlement === true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
entity: "expense",
|
|
296
|
+
id: activity.id ?? null,
|
|
297
|
+
title: activity.title ?? null,
|
|
298
|
+
amount: numeric(activity.amount),
|
|
299
|
+
currency: currencyCode(activity.currency),
|
|
300
|
+
date: activity.date ?? null,
|
|
301
|
+
paidBy: cleanUserSummary(activity.paidByUser),
|
|
302
|
+
category: asRecord(activity.category).name ?? null,
|
|
303
|
+
splitType: activity.splitType ?? null,
|
|
304
|
+
isRecurring: activity.recurringExpenseRuleId != null,
|
|
305
|
+
};
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function cleanCreatedGroup(body: unknown) {
|
|
310
|
+
const group = asRecord(body);
|
|
311
|
+
return {
|
|
312
|
+
id: group.id ?? null,
|
|
313
|
+
name: group.name ?? null,
|
|
314
|
+
description: group.description ?? null,
|
|
315
|
+
type: group.type ?? null,
|
|
316
|
+
currencyId: group.currencyId ?? null,
|
|
317
|
+
creatorId: group.creatorId ?? null,
|
|
318
|
+
defaultSplitType: group.defaultSplitType ?? null,
|
|
319
|
+
memberBalanceVisibility: group.memberBalanceVisibility ?? null,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function cleanMemberDetail(body: unknown) {
|
|
324
|
+
const member = asRecord(body);
|
|
325
|
+
const user = asRecord(member.user);
|
|
326
|
+
return {
|
|
327
|
+
id: member.id ?? null,
|
|
328
|
+
userId: member.userId ?? user.id ?? null,
|
|
329
|
+
name: user.name ?? member.name ?? null,
|
|
330
|
+
username: user.displayUsername ?? user.username ?? null,
|
|
331
|
+
email: user.email ?? null,
|
|
332
|
+
bio: user.bio ?? null,
|
|
333
|
+
role: member.role ?? null,
|
|
334
|
+
isGuest: member.isGuest === true,
|
|
335
|
+
isGold: member.isGold === true,
|
|
336
|
+
defaultSplitPercentage: numeric(member.defaultSplitPercentage),
|
|
337
|
+
joinedAt: member.joinedAt ?? null,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function cleanExpenseDetail(body: unknown) {
|
|
342
|
+
const expense = asRecord(body);
|
|
343
|
+
const recurrence = asRecord(expense.recurrence);
|
|
344
|
+
return {
|
|
345
|
+
id: expense.id ?? null,
|
|
346
|
+
title: expense.title ?? null,
|
|
347
|
+
description: expense.description ?? null,
|
|
348
|
+
amount: numeric(expense.amount),
|
|
349
|
+
currency: currencyCode(expense.currency),
|
|
350
|
+
date: expense.date ?? null,
|
|
351
|
+
timezone: expense.timezone ?? null,
|
|
352
|
+
paidBy: cleanUserSummary(expense.paidByUser),
|
|
353
|
+
creator: cleanUserSummary(expense.creator),
|
|
354
|
+
group: cleanUserSummary(expense.group),
|
|
355
|
+
category: asRecord(expense.category).name ?? null,
|
|
356
|
+
splitType: expense.splitType ?? null,
|
|
357
|
+
isRecurring:
|
|
358
|
+
expense.recurringExpenseRuleId != null || expense.recurrence != null,
|
|
359
|
+
recurrence:
|
|
360
|
+
expense.recurrence == null
|
|
361
|
+
? null
|
|
362
|
+
: {
|
|
363
|
+
frequency: recurrence.frequency ?? null,
|
|
364
|
+
interval: numeric(recurrence.interval),
|
|
365
|
+
},
|
|
366
|
+
splits: asArray(expense.shares).map((value) => {
|
|
367
|
+
const share = asRecord(value);
|
|
368
|
+
return {
|
|
369
|
+
user: cleanUserSummary(share.user),
|
|
370
|
+
userId: share.userId ?? null,
|
|
371
|
+
amount: numeric(share.amount),
|
|
372
|
+
};
|
|
373
|
+
}),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function cleanPaymentDetail(body: unknown) {
|
|
378
|
+
const payment = asRecord(body);
|
|
379
|
+
return {
|
|
380
|
+
id: payment.id ?? null,
|
|
381
|
+
description: payment.description ?? null,
|
|
382
|
+
amount: numeric(payment.amount),
|
|
383
|
+
currency: currencyCode(payment.currency),
|
|
384
|
+
date: payment.date ?? null,
|
|
385
|
+
timezone: payment.timezone ?? null,
|
|
386
|
+
from: cleanUserSummary(payment.fromUser),
|
|
387
|
+
to: cleanUserSummary(payment.toUser),
|
|
388
|
+
creator: cleanUserSummary(payment.creator),
|
|
389
|
+
group: cleanUserSummary(payment.group),
|
|
390
|
+
isSettlement: payment.isSettlement === true,
|
|
391
|
+
usedOptimalSettlement: payment.usedOptimalSettlement === true,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function formatGroup(body: unknown) {
|
|
396
|
+
const response = asRecord(body);
|
|
397
|
+
return [
|
|
398
|
+
`Name: ${display(response.name)}`,
|
|
399
|
+
`Description: ${display(response.description)}`,
|
|
400
|
+
`Type: ${display(response.type)}`,
|
|
401
|
+
`Currency: ${display(response.currency)}`,
|
|
402
|
+
`Balance: ${humanAmount(response.balance, response.currency)}`,
|
|
403
|
+
`Owed: ${humanAmount(response.totalOwed, response.currency)}`,
|
|
404
|
+
`Owing: ${humanAmount(response.totalOwing, response.currency)}`,
|
|
405
|
+
`Members: ${display(response.memberCount)}`,
|
|
406
|
+
`Default split: ${display(response.defaultSplitType)}`,
|
|
407
|
+
`Optimal settlement: ${yesNo(response.useOptimalSettlement)}`,
|
|
408
|
+
`Balance visibility: ${display(response.memberBalanceVisibility)}`,
|
|
409
|
+
`ID: ${display(response.id)}`,
|
|
410
|
+
].join("\n");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function activityDetailPath(item: Record<string, unknown>) {
|
|
414
|
+
const collection = item.entity === "payment" ? "payments" : "expenses";
|
|
415
|
+
return `/${collection}/${encodedDetailId(item.id)}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function formatActivityDetail(item: Record<string, unknown>, body: unknown) {
|
|
419
|
+
if (item.entity === "payment") {
|
|
420
|
+
const payment = asRecord(cleanPaymentDetail(body));
|
|
421
|
+
return [
|
|
422
|
+
`Description: ${display(payment.description)}`,
|
|
423
|
+
`Amount: ${humanAmount(payment.amount, payment.currency)}`,
|
|
424
|
+
`From: ${namedEntity(payment.from)}`,
|
|
425
|
+
`To: ${namedEntity(payment.to)}`,
|
|
426
|
+
`Settlement: ${yesNo(payment.isSettlement)}`,
|
|
427
|
+
`Optimal settlement: ${yesNo(payment.usedOptimalSettlement)}`,
|
|
428
|
+
`Date: ${display(payment.date)}`,
|
|
429
|
+
`Timezone: ${display(payment.timezone)}`,
|
|
430
|
+
`Group: ${namedEntity(payment.group)}`,
|
|
431
|
+
`Created by: ${namedEntity(payment.creator)}`,
|
|
432
|
+
`ID: ${display(payment.id)}`,
|
|
433
|
+
].join("\n");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const expense = asRecord(cleanExpenseDetail(body));
|
|
437
|
+
const recurrence = asRecord(expense.recurrence);
|
|
438
|
+
const splits = asArray(expense.splits);
|
|
439
|
+
return [
|
|
440
|
+
`Description: ${display(expense.description)}`,
|
|
441
|
+
`Amount: ${humanAmount(expense.amount, expense.currency)}`,
|
|
442
|
+
`Paid by: ${namedEntity(expense.paidBy)}`,
|
|
443
|
+
`Category: ${display(expense.category)}`,
|
|
444
|
+
`Split: ${display(expense.splitType)}`,
|
|
445
|
+
`Recurring: ${yesNo(expense.isRecurring)}`,
|
|
446
|
+
...(expense.recurrence == null
|
|
447
|
+
? []
|
|
448
|
+
: [
|
|
449
|
+
`Recurrence frequency: ${display(recurrence.frequency)}`,
|
|
450
|
+
`Recurrence interval: ${display(recurrence.interval)}`,
|
|
451
|
+
]),
|
|
452
|
+
`Date: ${display(expense.date)}`,
|
|
453
|
+
`Timezone: ${display(expense.timezone)}`,
|
|
454
|
+
`Group: ${namedEntity(expense.group)}`,
|
|
455
|
+
`Created by: ${namedEntity(expense.creator)}`,
|
|
456
|
+
`ID: ${display(expense.id)}`,
|
|
457
|
+
"",
|
|
458
|
+
"Splits",
|
|
459
|
+
...(splits.length
|
|
460
|
+
? splits.map((value) => {
|
|
461
|
+
const split = asRecord(value);
|
|
462
|
+
const user = asRecord(split.user);
|
|
463
|
+
return `${display(user.name ?? split.userId)}: ${humanAmount(split.amount, expense.currency)}${
|
|
464
|
+
split.userId ? ` · ${String(split.userId)}` : ""
|
|
465
|
+
}`;
|
|
466
|
+
})
|
|
467
|
+
: ["—"]),
|
|
468
|
+
].join("\n");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export const groupPresenters = {
|
|
472
|
+
"group-list": {
|
|
473
|
+
clean: cleanGroupList,
|
|
474
|
+
format(body) {
|
|
475
|
+
const response = asRecord(body);
|
|
476
|
+
const items = asArray(response.items);
|
|
477
|
+
const lines = items.length
|
|
478
|
+
? [
|
|
479
|
+
"Groups",
|
|
480
|
+
...items.map((value, index) => {
|
|
481
|
+
const group = asRecord(value);
|
|
482
|
+
return formatCard(index, group.name, [
|
|
483
|
+
`ID: ${display(group.id)}`,
|
|
484
|
+
`Type: ${display(group.type)}`,
|
|
485
|
+
`Description: ${display(group.description)}`,
|
|
486
|
+
`Balance: ${humanAmount(group.balance, group.currency)}`,
|
|
487
|
+
`Members: ${display(group.memberCount)}`,
|
|
488
|
+
`Last activity: ${display(group.mostRecentActivity)}`,
|
|
489
|
+
]);
|
|
490
|
+
}),
|
|
491
|
+
]
|
|
492
|
+
: ["No groups."];
|
|
493
|
+
lines.push(
|
|
494
|
+
response.hasMore && response.nextCursor
|
|
495
|
+
? [
|
|
496
|
+
"More groups available.",
|
|
497
|
+
`Next page: banana groups list --cursor ${JSON.stringify(response.nextCursor)}`,
|
|
498
|
+
].join("\n")
|
|
499
|
+
: "End of groups.",
|
|
500
|
+
);
|
|
501
|
+
return lines.join("\n\n");
|
|
502
|
+
},
|
|
503
|
+
browser: {
|
|
504
|
+
detailPath(_command, item) {
|
|
505
|
+
return `/groups/${encodedDetailId(item.id)}`;
|
|
506
|
+
},
|
|
507
|
+
formatDetail(item, body) {
|
|
508
|
+
return formatGroup(cleanGroup({
|
|
509
|
+
...asRecord(body),
|
|
510
|
+
memberCount: item.memberCount,
|
|
511
|
+
}));
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
group: { clean: cleanGroup, format: formatGroup },
|
|
516
|
+
members: {
|
|
517
|
+
clean: cleanMembers,
|
|
518
|
+
format(body) {
|
|
519
|
+
const items = asArray(body);
|
|
520
|
+
if (!items.length) return "No group members.";
|
|
521
|
+
return [
|
|
522
|
+
"Group members",
|
|
523
|
+
...items.map((value, index) => {
|
|
524
|
+
const member = asRecord(value);
|
|
525
|
+
return formatCard(index, member.name, [
|
|
526
|
+
`Role: ${display(member.role)}`,
|
|
527
|
+
`Guest: ${yesNo(member.isGuest)}`,
|
|
528
|
+
`Gold: ${yesNo(member.isGold)}`,
|
|
529
|
+
`Default split: ${display(member.defaultSplitPercentage)}`,
|
|
530
|
+
`Joined: ${display(member.joinedAt)}`,
|
|
531
|
+
`Member ID: ${display(member.id)}`,
|
|
532
|
+
`User ID: ${display(member.userId)}`,
|
|
533
|
+
]);
|
|
534
|
+
}),
|
|
535
|
+
].join("\n\n");
|
|
536
|
+
},
|
|
537
|
+
browser: {
|
|
538
|
+
detailPath(command, item) {
|
|
539
|
+
return `${command.path}/${encodedDetailId(item.id)}`;
|
|
540
|
+
},
|
|
541
|
+
formatDetail(_item, body) {
|
|
542
|
+
const member = asRecord(cleanMemberDetail(body));
|
|
543
|
+
return [
|
|
544
|
+
`Name: ${display(member.name)}`,
|
|
545
|
+
`Username: ${display(member.username)}`,
|
|
546
|
+
`Email: ${display(member.email)}`,
|
|
547
|
+
`Bio: ${display(member.bio)}`,
|
|
548
|
+
`Role: ${display(member.role)}`,
|
|
549
|
+
`Guest: ${yesNo(member.isGuest)}`,
|
|
550
|
+
`Gold: ${yesNo(member.isGold)}`,
|
|
551
|
+
`Default split: ${display(member.defaultSplitPercentage)}`,
|
|
552
|
+
`Joined: ${display(member.joinedAt)}`,
|
|
553
|
+
`Member ID: ${display(member.id)}`,
|
|
554
|
+
`User ID: ${display(member.userId)}`,
|
|
555
|
+
].join("\n");
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
activities: {
|
|
560
|
+
clean: cleanActivities,
|
|
561
|
+
format(body) {
|
|
562
|
+
const items = asArray(body);
|
|
563
|
+
if (!items.length) return "No group activities.";
|
|
564
|
+
return [
|
|
565
|
+
"Group activities",
|
|
566
|
+
...items.map((value, index) => {
|
|
567
|
+
const activity = asRecord(value);
|
|
568
|
+
if (activity.entity === "payment") {
|
|
569
|
+
const from = asRecord(activity.from);
|
|
570
|
+
const to = asRecord(activity.to);
|
|
571
|
+
return formatCard(
|
|
572
|
+
index,
|
|
573
|
+
`Payment: ${display(activity.description)}`,
|
|
574
|
+
[
|
|
575
|
+
`Amount: ${humanAmount(activity.amount, activity.currency)}`,
|
|
576
|
+
`From: ${display(from.name)}`,
|
|
577
|
+
`To: ${display(to.name)}`,
|
|
578
|
+
`Settlement: ${yesNo(activity.isSettlement)}`,
|
|
579
|
+
`Date: ${display(activity.date)}`,
|
|
580
|
+
`ID: ${display(activity.id)}`,
|
|
581
|
+
],
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const paidBy = asRecord(activity.paidBy);
|
|
585
|
+
return formatCard(
|
|
586
|
+
index,
|
|
587
|
+
`Expense: ${display(activity.title)}`,
|
|
588
|
+
[
|
|
589
|
+
`Amount: ${humanAmount(activity.amount, activity.currency)}`,
|
|
590
|
+
`Paid by: ${display(paidBy.name)}`,
|
|
591
|
+
`Category: ${display(activity.category)}`,
|
|
592
|
+
`Split: ${display(activity.splitType)}`,
|
|
593
|
+
`Recurring: ${yesNo(activity.isRecurring)}`,
|
|
594
|
+
`Date: ${display(activity.date)}`,
|
|
595
|
+
`ID: ${display(activity.id)}`,
|
|
596
|
+
],
|
|
597
|
+
);
|
|
598
|
+
}),
|
|
599
|
+
].join("\n\n");
|
|
600
|
+
},
|
|
601
|
+
browser: {
|
|
602
|
+
detailPath(_command, item) {
|
|
603
|
+
return activityDetailPath(item);
|
|
604
|
+
},
|
|
605
|
+
formatDetail: formatActivityDetail,
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
"group-created": {
|
|
609
|
+
clean: cleanCreatedGroup,
|
|
610
|
+
format(body) {
|
|
611
|
+
const response = asRecord(body);
|
|
612
|
+
return [
|
|
613
|
+
"Group created",
|
|
614
|
+
`Name: ${display(response.name)}`,
|
|
615
|
+
`Description: ${display(response.description)}`,
|
|
616
|
+
`Type: ${display(response.type)}`,
|
|
617
|
+
`Currency ID: ${display(response.currencyId)}`,
|
|
618
|
+
`Default split: ${display(response.defaultSplitType)}`,
|
|
619
|
+
`Balance visibility: ${display(response.memberBalanceVisibility)}`,
|
|
620
|
+
`ID: ${display(response.id)}`,
|
|
621
|
+
].join("\n");
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
} satisfies Record<
|
|
625
|
+
"group-list" | "group" | "members" | "activities" | "group-created",
|
|
626
|
+
Presenter
|
|
627
|
+
>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asRecord,
|
|
3
|
+
display,
|
|
4
|
+
parseOptions,
|
|
5
|
+
requirePositionals,
|
|
6
|
+
wantsHelp,
|
|
7
|
+
yesNo,
|
|
8
|
+
} from "../shared";
|
|
9
|
+
import type { ParsedCommand, Presenter } from "../types";
|
|
10
|
+
|
|
11
|
+
const HELP = "Usage: banana me";
|
|
12
|
+
|
|
13
|
+
export function parseMe(args: string[]): ParsedCommand {
|
|
14
|
+
if (wantsHelp(args)) return { kind: "help", text: HELP };
|
|
15
|
+
const { positionals } = parseOptions(args);
|
|
16
|
+
requirePositionals(positionals, 0, HELP);
|
|
17
|
+
return { kind: "request", path: "/current-user", presentation: "user" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const mePresenters = {
|
|
21
|
+
user: {
|
|
22
|
+
clean(body) {
|
|
23
|
+
const user = asRecord(body);
|
|
24
|
+
return {
|
|
25
|
+
id: user.id ?? null,
|
|
26
|
+
name: user.name ?? null,
|
|
27
|
+
email: user.email ?? null,
|
|
28
|
+
username: user.username ?? null,
|
|
29
|
+
isGuest: user.isGuest === true,
|
|
30
|
+
currencyId: user.currencyId ?? null,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
format(body) {
|
|
34
|
+
const user = asRecord(body);
|
|
35
|
+
return [
|
|
36
|
+
`Name: ${display(user.name)}`,
|
|
37
|
+
`Email: ${display(user.email)}`,
|
|
38
|
+
`Username: ${display(user.username)}`,
|
|
39
|
+
`Guest: ${yesNo(user.isGuest)}`,
|
|
40
|
+
`Currency ID: ${display(user.currencyId)}`,
|
|
41
|
+
`ID: ${display(user.id)}`,
|
|
42
|
+
].join("\n");
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} satisfies Record<"user", Presenter>;
|