Skip to content

Commit a7fb0db

Browse files
authored
feat: individual connect account dashboard page (#92)
1 parent 12492d9 commit a7fb0db

43 files changed

Lines changed: 2442 additions & 188 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/routes/accounts.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ async function PopulateAccountResources(
5858
account.individual = person;
5959
}
6060

61-
// External accounts and login links are only returned when controller.is_controller is true
62-
if (isPlatformRequest && account.controller?.is_controller) {
61+
// External accounts and login links are returned for platform/controller requests
62+
if (isPlatformRequest) {
6363
const [externalWallets, loginLinkRecords] = await Promise.all([
6464
externalWalletModule.GetExternalWalletsByAccount(account.id),
6565
loginLinkModule.GetLoginLinksByAccount(account.id),

apps/api/src/routes/balanceTransactions.routes.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { db } from '../modules/Database';
99
import { BalanceTransactionModule } from '../modules/BalanceTransaction';
1010
import { AccountModule } from '../modules/Account';
1111
import { CanAccessAccount } from '../modules/PlatformAccess';
12+
import { OptionalConnectedAccount } from '../middleware/Authorization';
1213

1314
const router = express.Router();
1415

@@ -34,10 +35,15 @@ const accountModule = new AccountModule(db);
3435
*/
3536
router.get(
3637
'/',
38+
OptionalConnectedAccount('zoneless-account'),
3739
AsyncHandler(async (req: express.Request, res: express.Response) => {
38-
const accountId = req.user.account;
40+
// Platforms may pass Zoneless-Account to list a connected account's transactions
41+
const accountId = req.connectedAccount?.id ?? req.user.account;
3942

40-
Logger.info('Listing balance transactions', { accountId });
43+
Logger.info('Listing balance transactions', {
44+
accountId,
45+
onBehalf: !!req.connectedAccount,
46+
});
4147

4248
const limit = req.query.limit
4349
? parseInt(req.query.limit as string, 10)

apps/api/src/routes/payouts.routes.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
RequirePlatform,
1313
RequireResourceOwnership,
1414
RequireConnectedAccountOwnership,
15+
OptionalConnectedAccount,
1516
} from '../middleware/Authorization';
1617
import {
1718
CreatePayoutSchema,
@@ -289,6 +290,7 @@ router.get(
289290
*/
290291
router.get(
291292
'/',
293+
OptionalConnectedAccount('zoneless-account'),
292294
AsyncHandler(async (req: express.Request, res: express.Response) => {
293295
const limit = req.query.limit
294296
? parseInt(req.query.limit as string, 10)
@@ -313,9 +315,22 @@ router.get(
313315
| undefined;
314316

315317
try {
316-
// For platforms, get payouts for all connected accounts using platform_account
317-
// For connected accounts, only get their own payouts
318-
if (req.user.platform) {
318+
// Platform acting on behalf of a connected account
319+
if (req.connectedAccount) {
320+
const result = await payoutModule.ListPayouts({
321+
account: req.connectedAccount.id,
322+
limit,
323+
startingAfter,
324+
endingBefore,
325+
status: statusFilter,
326+
destination,
327+
created,
328+
arrivalDate,
329+
});
330+
331+
res.json(result);
332+
} else if (req.user.platform) {
333+
// For platforms, get payouts for all connected accounts using platform_account
319334
const result = await payoutModule.ListPayoutsByPlatform({
320335
platformAccount: req.user.account,
321336
limit,

apps/api/src/routes/transfers.routes.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ValidateRequest } from '../middleware/ValidateRequest';
2222
import {
2323
RequirePlatform,
2424
RequireResourceOwnership,
25+
OptionalConnectedAccount,
2526
} from '../middleware/Authorization';
2627
import {
2728
CreateTransferSchema,
@@ -131,12 +132,44 @@ const requireTransferReadAccess = AsyncHandler(
131132
router.post(
132133
'/',
133134
RequirePlatform(),
135+
OptionalConnectedAccount('zoneless-account'),
134136
ValidateRequest(CreateTransferSchema),
135137
AsyncHandler(async (req: express.Request, res: express.Response) => {
136138
const platformAccountId = req.user.account;
137139
const { destination } = req.body;
138140

139-
// Verify the destination account belongs to this platform
141+
// Pull funds: platform acts on behalf of connected account → platform balance
142+
if (req.connectedAccount) {
143+
if (destination !== platformAccountId) {
144+
throw new AppError(
145+
'When using Zoneless-Account, destination must be the platform account.',
146+
ERRORS.INVALID_REQUEST.status,
147+
ERRORS.INVALID_REQUEST.type
148+
);
149+
}
150+
151+
Logger.info('Creating pull-funds transfer', {
152+
account: req.connectedAccount.id,
153+
amount: req.body.amount,
154+
destination: platformAccountId,
155+
});
156+
157+
const transfer = await transferModule.CreateTransfer(
158+
req.connectedAccount.id,
159+
req.body
160+
);
161+
162+
Logger.info('Pull-funds transfer created successfully', {
163+
transferId: transfer.id,
164+
amount: transfer.amount,
165+
destination: transfer.destination,
166+
});
167+
168+
res.status(201).json(transfer);
169+
return;
170+
}
171+
172+
// Add funds: platform → connected account
140173
const destinationAccount = await accountModule.GetAccount(destination);
141174

142175
if (!destinationAccount) {

apps/web/src/app/core/services/api.service.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface ApiOptions {
66
timeout?: number;
77
retries?: number;
88
retryDelay?: number;
9+
/** Act on behalf of a connected account (sends Zoneless-Account header) */
10+
zonelessAccount?: string;
911
}
1012

1113
const DEFAULT_TIMEOUT = 30000; // 30 seconds
@@ -42,7 +44,8 @@ export class ApiService {
4244
method,
4345
endpoint,
4446
parameters,
45-
timeout
47+
timeout,
48+
options.zonelessAccount
4649
);
4750
} catch (error) {
4851
lastError = error instanceof Error ? error : new Error(String(error));
@@ -67,7 +70,8 @@ export class ApiService {
6770
method: string,
6871
endpoint: string,
6972
parameters: object,
70-
timeout: number
73+
timeout: number,
74+
zonelessAccount?: string
7175
): Promise<T> {
7276
const cleanEndpoint = endpoint.startsWith('/')
7377
? endpoint.substring(1)
@@ -78,20 +82,23 @@ export class ApiService {
7882
const timeoutId = setTimeout(() => controller.abort(), timeout);
7983

8084
try {
81-
const options: RequestInit = {
82-
method: method,
83-
headers: {
84-
'Content-Type': 'application/json',
85-
},
86-
signal: controller.signal,
85+
const headers: Record<string, string> = {
86+
'Content-Type': 'application/json',
8787
};
8888

8989
const token = this.storage.GetItemString('auth_token');
9090
if (token) {
91-
(options.headers as Record<string, string>)[
92-
'Authorization'
93-
] = `Bearer ${token}`;
91+
headers['Authorization'] = `Bearer ${token}`;
9492
}
93+
if (zonelessAccount) {
94+
headers['Zoneless-Account'] = zonelessAccount;
95+
}
96+
97+
const options: RequestInit = {
98+
method: method,
99+
headers,
100+
signal: controller.signal,
101+
};
95102

96103
if (method.toUpperCase() === 'GET') {
97104
const urlParams = new URLSearchParams(

apps/web/src/app/data/services/account.service.ts

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,8 @@ export class AccountService {
1717
account: WritableSignal<Account | null> = signal(null);
1818
loading: WritableSignal<boolean> = signal(false);
1919

20-
// Connected account selection state (for viewing connected accounts in panel)
21-
selectedConnectedAccount: WritableSignal<Account | null> = signal(null);
22-
loadingConnectedAccount: WritableSignal<boolean> = signal(false);
23-
2420
Reset(): void {
2521
this.account.set(null);
26-
this.selectedConnectedAccount.set(null);
27-
this.loadingConnectedAccount.set(false);
2822
}
2923

3024
async GetAccount(): Promise<Account | null> {
@@ -53,7 +47,10 @@ export class AccountService {
5347
`accounts/${accountId}`,
5448
data
5549
);
56-
this.account.set(account);
50+
// Only update the signed-in account signal when editing self
51+
if (this.account()?.id === accountId) {
52+
this.account.set(account);
53+
}
5754
return account;
5855
} finally {
5956
this.loading.set(false);
@@ -87,34 +84,10 @@ export class AccountService {
8784
}
8885

8986
/**
90-
* Fetch any account by ID (used for viewing connected accounts).
91-
* Sets the selectedConnectedAccount signal for panel display.
92-
*/
93-
async LoadConnectedAccount(accountId: string): Promise<Account | null> {
94-
this.loadingConnectedAccount.set(true);
95-
this.selectedConnectedAccount.set(null);
96-
97-
try {
98-
const account = await this.api.Call<Account>(
99-
'GET',
100-
`accounts/${accountId}`
101-
);
102-
this.selectedConnectedAccount.set(account);
103-
return account;
104-
} catch (error) {
105-
console.error('Failed to load connected account:', error);
106-
this.selectedConnectedAccount.set(null);
107-
return null;
108-
} finally {
109-
this.loadingConnectedAccount.set(false);
110-
}
111-
}
112-
113-
/**
114-
* Clear the selected connected account.
87+
* Fetch a connected account by ID.
11588
*/
116-
ClearSelectedConnectedAccount(): void {
117-
this.selectedConnectedAccount.set(null);
89+
async GetConnectedAccount(accountId: string): Promise<Account> {
90+
return this.api.Call<Account>('GET', `accounts/${accountId}`);
11891
}
11992

12093
/**

apps/web/src/app/data/services/balance.service.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@ export class BalanceService {
1919
this.balanceDetails.set(null);
2020
}
2121

22-
async GetBalance(): Promise<Balance> {
22+
async GetBalance(zonelessAccount?: string): Promise<Balance> {
2323
this.loading.set(true);
2424
try {
25-
const balance = await this.api.Call<Balance>('GET', 'balance');
26-
this.balance.set(balance);
25+
const balance = await this.api.Call<Balance>(
26+
'GET',
27+
'balance',
28+
{},
29+
zonelessAccount ? { zonelessAccount } : undefined
30+
);
31+
if (!zonelessAccount) {
32+
this.balance.set(balance);
33+
}
2734
return balance;
2835
} finally {
2936
this.loading.set(false);

apps/web/src/app/data/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ export * from './setup.service';
1717
export * from './subscription.service';
1818
export * from './telemetry.service';
1919
export * from './topup.service';
20+
export * from './transfer.service';
2021
export * from './transaction.service';
2122
export * from './webhook-endpoint.service';

apps/web/src/app/data/services/person.service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,15 @@ export class PersonService {
9898
const lines: string[] = [];
9999
if (person.address.line1) lines.push(person.address.line1);
100100
if (person.address.line2) lines.push(person.address.line2);
101-
if (person.address.city) lines.push(person.address.city);
102-
if (person.address.postal_code) lines.push(person.address.postal_code);
101+
102+
const cityState = [person.address.city, person.address.state]
103+
.filter(Boolean)
104+
.join(', ');
105+
const locality = [cityState, person.address.postal_code]
106+
.filter(Boolean)
107+
.join(' ');
108+
if (locality) lines.push(locality);
109+
103110
if (person.address.country) {
104111
const countryName = GetCountryName(person.address.country);
105112
lines.push(countryName || person.address.country);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Injectable, inject } from '@angular/core';
2+
import { ApiService } from '../../core';
3+
import type { Transfer } from '@zoneless/shared-types';
4+
import type { CreateTransferInput } from '@zoneless/shared-schemas';
5+
6+
@Injectable({
7+
providedIn: 'root',
8+
})
9+
export class TransferService {
10+
private readonly api = inject(ApiService);
11+
12+
/**
13+
* Create a transfer from the platform balance to a connected account.
14+
*/
15+
async CreateTransfer(input: CreateTransferInput): Promise<Transfer> {
16+
return this.api.Call<Transfer>('POST', 'transfers', input);
17+
}
18+
19+
/**
20+
* Create a transfer on behalf of a connected account (Zoneless-Account header).
21+
* The connected account is the source; destination is typically the platform.
22+
*/
23+
async CreateTransferForConnectedAccount(
24+
connectedAccountId: string,
25+
input: CreateTransferInput
26+
): Promise<Transfer> {
27+
return this.api.Call<Transfer>('POST', 'transfers', input, {
28+
zonelessAccount: connectedAccountId,
29+
});
30+
}
31+
32+
async GetTransfer(transferId: string): Promise<Transfer> {
33+
return this.api.Call<Transfer>('GET', `transfers/${transferId}`);
34+
}
35+
}

0 commit comments

Comments
 (0)