Skip to content

Commit c262717

Browse files
josunday002jhayniffy
authored andcommitted
docs(subscriptions): document endpoints in Swagger/OpenAPI
Add typed DTOs for checkout request/response bodies (CreateCheckoutDto, ValidateBalanceDto, ConfirmSubscriptionDto, etc.) and wire them into controller with @apiresponse type references and detailed descriptions.
1 parent b8dd856 commit c262717

3 files changed

Lines changed: 279 additions & 27 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator';
2+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3+
4+
export class CreateCheckoutDto {
5+
@ApiProperty({ description: 'Fan Stellar G-address' })
6+
@IsString()
7+
@IsNotEmpty()
8+
fanAddress: string;
9+
10+
@ApiProperty({ description: 'Creator Stellar G-address' })
11+
@IsString()
12+
@IsNotEmpty()
13+
creatorAddress: string;
14+
15+
@ApiProperty({ description: 'Subscription plan ID', minimum: 1 })
16+
@IsInt()
17+
@Min(1)
18+
planId: number;
19+
20+
@ApiPropertyOptional({ description: 'Asset code (default: XLM)', default: 'XLM' })
21+
@IsOptional()
22+
@IsString()
23+
assetCode?: string;
24+
25+
@ApiPropertyOptional({ description: 'Asset issuer address (for non-native assets)' })
26+
@IsOptional()
27+
@IsString()
28+
assetIssuer?: string;
29+
}
30+
31+
export class CheckoutResponseDto {
32+
@ApiProperty({ description: 'Checkout session ID' }) id: string;
33+
@ApiProperty({ description: 'Fan Stellar G-address' }) fanAddress: string;
34+
@ApiProperty({ description: 'Creator Stellar G-address' }) creatorAddress: string;
35+
@ApiProperty({ description: 'Subscription plan ID' }) planId: number;
36+
@ApiProperty({ description: 'Asset code' }) assetCode: string;
37+
@ApiPropertyOptional({ description: 'Asset issuer address' }) assetIssuer?: string;
38+
@ApiProperty({ description: 'Subscription amount' }) amount: string;
39+
@ApiProperty({ description: 'Platform fee' }) fee: string;
40+
@ApiProperty({ description: 'Total including fees' }) total: string;
41+
@ApiProperty({ description: 'Checkout status', enum: ['pending', 'completed', 'failed', 'rejected', 'expired'] }) status: string;
42+
@ApiProperty({ description: 'Session expiry timestamp' }) expiresAt: Date;
43+
@ApiPropertyOptional({ description: 'Transaction hash (after confirmation)' }) txHash?: string;
44+
@ApiPropertyOptional({ description: 'Error message (on failure)' }) error?: string;
45+
@ApiProperty({ description: 'Creation timestamp' }) createdAt: Date;
46+
@ApiProperty({ description: 'Last update timestamp' }) updatedAt: Date;
47+
}
48+
49+
export class ValidateBalanceDto {
50+
@ApiProperty({ description: 'Asset code to check balance for' })
51+
@IsString()
52+
@IsNotEmpty()
53+
assetCode: string;
54+
55+
@ApiProperty({ description: 'Required amount' })
56+
@IsString()
57+
@IsNotEmpty()
58+
amount: string;
59+
}
60+
61+
export class ValidateBalanceResponseDto {
62+
@ApiProperty({ description: 'Whether balance is sufficient' }) valid: boolean;
63+
@ApiProperty({ description: 'Current balance' }) balance: string;
64+
@ApiPropertyOptional({ description: 'Amount short (if insufficient)' }) shortfall?: string;
65+
}
66+
67+
export class ConfirmSubscriptionDto {
68+
@ApiPropertyOptional({ description: 'Transaction hash from Stellar network' })
69+
@IsOptional()
70+
@IsString()
71+
txHash?: string;
72+
}
73+
74+
export class ConfirmSubscriptionResponseDto {
75+
@ApiProperty() success: boolean;
76+
@ApiProperty() checkoutId: string;
77+
@ApiProperty() status: string;
78+
@ApiProperty() txHash: string;
79+
@ApiProperty() explorerUrl: string;
80+
@ApiProperty() subscriptionId: string;
81+
@ApiProperty({ enum: ['created', 'renewed'] }) lifecycleEvent: string;
82+
@ApiProperty() message: string;
83+
}
84+
85+
export class FailCheckoutDto {
86+
@ApiProperty({ description: 'Error message describing the failure' })
87+
@IsString()
88+
@IsNotEmpty()
89+
error: string;
90+
91+
@ApiPropertyOptional({ description: 'Whether the transaction was rejected by the user', default: false })
92+
@IsOptional()
93+
rejected?: boolean;
94+
}
95+
96+
export class CancelSubscriptionDto {
97+
@ApiProperty({ description: 'Fan Stellar G-address' })
98+
@IsString()
99+
@IsNotEmpty()
100+
fanAddress: string;
101+
102+
@ApiProperty({ description: 'Creator Stellar G-address' })
103+
@IsString()
104+
@IsNotEmpty()
105+
creatorAddress: string;
106+
}
107+
108+
export class PlanSummaryResponseDto {
109+
@ApiProperty() id: number;
110+
@ApiProperty() creatorName: string;
111+
@ApiProperty() creatorAddress: string;
112+
@ApiProperty() name: string;
113+
@ApiPropertyOptional() description?: string;
114+
@ApiProperty() assetCode: string;
115+
@ApiPropertyOptional() assetIssuer?: string;
116+
@ApiProperty() amount: string;
117+
@ApiProperty() interval: string;
118+
@ApiProperty() intervalDays: number;
119+
}
120+
121+
export class PriceBreakdownResponseDto {
122+
@ApiProperty() subtotal: string;
123+
@ApiProperty() platformFee: string;
124+
@ApiProperty() networkFee: string;
125+
@ApiProperty() total: string;
126+
@ApiProperty() currency: string;
127+
}
128+
129+
export class WalletStatusResponseDto {
130+
@ApiProperty() address: string;
131+
@ApiProperty({ isArray: true }) balances: {
132+
code: string;
133+
issuer?: string;
134+
balance: string;
135+
isNative: boolean;
136+
}[];
137+
@ApiProperty() isConnected: boolean;
138+
}
139+
140+
export class TransactionPreviewResponseDto {
141+
@ApiProperty() checkoutId: string;
142+
@ApiProperty() from: string;
143+
@ApiProperty() to: string;
144+
@ApiProperty() asset: { code: string; issuer?: string };
145+
@ApiProperty() amount: string;
146+
@ApiProperty() fee: string;
147+
@ApiProperty() total: string;
148+
@ApiProperty() memo: string;
149+
}

backend/src/subscriptions/subscriptions.controller.ts

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
1616
import { ListSubscriptionsQueryDto } from './dto/list-subscriptions-query.dto';
1717
import { ListCreatorSubscribersQueryDto } from './dto/list-creator-subscribers-query.dto';
1818
import { SubscriptionStateQueryDto } from './dto/subscription-state-query.dto';
19+
import {
20+
CreateCheckoutDto,
21+
CheckoutResponseDto,
22+
ValidateBalanceDto,
23+
ValidateBalanceResponseDto,
24+
ConfirmSubscriptionDto,
25+
ConfirmSubscriptionResponseDto,
26+
FailCheckoutDto,
27+
CancelSubscriptionDto,
28+
PlanSummaryResponseDto,
29+
PriceBreakdownResponseDto,
30+
WalletStatusResponseDto,
31+
TransactionPreviewResponseDto,
32+
} from './dto/checkout.dto';
1933
import { FanBearerGuard } from './guards/fan-bearer.guard';
2034
import type { RequestWithFan } from './guards/fan-bearer.guard';
2135
import { SubscriptionsService } from './subscriptions.service';
@@ -150,19 +164,13 @@ export class SubscriptionsController {
150164
@Throttle({ short: { limit: 10, ttl: 60000 } })
151165
@UseGuards(FeatureFlagGuard)
152166
@RequireFeatureFlag('newSubscriptionFlow')
153-
@ApiOperation({ summary: 'Create a subscription checkout session' })
154-
@ApiResponse({ status: 429, description: 'Too many requests' })
155-
@ApiResponse({ status: 201, description: 'Checkout session created' })
167+
@ApiOperation({ summary: 'Create a subscription checkout session', description: 'Initiates a new checkout session for subscribing to a creator plan. The session expires after 15 minutes.' })
168+
@ApiResponse({ status: 201, description: 'Checkout session created', type: CheckoutResponseDto })
156169
@ApiResponse({ status: 403, description: 'New subscription flow is disabled' })
170+
@ApiResponse({ status: 404, description: 'Plan not found' })
171+
@ApiResponse({ status: 429, description: 'Too many requests' })
157172
createCheckout(
158-
@Body()
159-
body: {
160-
fanAddress: string;
161-
creatorAddress: string;
162-
planId: number;
163-
assetCode?: string;
164-
assetIssuer?: string;
165-
},
173+
@Body() body: CreateCheckoutDto,
166174
@Headers('x-network') requestNetwork?: string,
167175
) {
168176
const checkout = this.subscriptionsService.createCheckout(
@@ -192,9 +200,10 @@ export class SubscriptionsController {
192200
}
193201

194202
@Get('checkout/:id')
195-
@ApiOperation({ summary: 'Get a checkout session by ID' })
203+
@ApiOperation({ summary: 'Get a checkout session by ID', description: 'Returns full checkout details including transaction hash and error if present.' })
196204
@ApiParam({ name: 'id', description: 'Checkout session ID' })
197-
@ApiResponse({ status: 200, description: 'Checkout session details' })
205+
@ApiResponse({ status: 200, description: 'Checkout session details', type: CheckoutResponseDto })
206+
@ApiResponse({ status: 400, description: 'Checkout session has expired' })
198207
@ApiResponse({ status: 404, description: 'Checkout not found' })
199208
getCheckout(@Param('id') checkoutId: string) {
200209
const checkout = this.subscriptionsService.getCheckout(checkoutId);
@@ -218,35 +227,39 @@ export class SubscriptionsController {
218227
}
219228

220229
@Get('checkout/:id/plan')
221-
@ApiOperation({ summary: 'Get plan summary for a checkout session' })
230+
@ApiOperation({ summary: 'Get plan summary for a checkout session', description: 'Returns creator name, asset, amount, and billing interval for the plan attached to this checkout.' })
222231
@ApiParam({ name: 'id', description: 'Checkout session ID' })
223-
@ApiResponse({ status: 200, description: 'Plan summary' })
232+
@ApiResponse({ status: 200, description: 'Plan summary', type: PlanSummaryResponseDto })
233+
@ApiResponse({ status: 404, description: 'Checkout or plan not found' })
224234
getPlanSummary(@Param('id') checkoutId: string) {
225235
const checkout = this.subscriptionsService.getCheckout(checkoutId);
226236
return this.subscriptionsService.getPlanSummary(checkout.planId);
227237
}
228238

229239
@Get('checkout/:id/price')
230-
@ApiOperation({ summary: 'Get price breakdown for a checkout session' })
240+
@ApiOperation({ summary: 'Get price breakdown for a checkout session', description: 'Returns subtotal, platform fee, network fee, and total for the checkout.' })
231241
@ApiParam({ name: 'id', description: 'Checkout session ID' })
232-
@ApiResponse({ status: 200, description: 'Price breakdown' })
242+
@ApiResponse({ status: 200, description: 'Price breakdown', type: PriceBreakdownResponseDto })
243+
@ApiResponse({ status: 404, description: 'Checkout not found' })
233244
getPriceBreakdown(@Param('id') checkoutId: string) {
234245
return this.subscriptionsService.getPriceBreakdown(checkoutId);
235246
}
236247

237248
@Get('checkout/:id/wallet')
238-
@ApiOperation({ summary: 'Get wallet status for a checkout session' })
249+
@ApiOperation({ summary: 'Get wallet status for a checkout session', description: 'Returns the fan wallet balances and connection status for the checkout session.' })
239250
@ApiParam({ name: 'id', description: 'Checkout session ID' })
240-
@ApiResponse({ status: 200, description: 'Wallet status' })
251+
@ApiResponse({ status: 200, description: 'Wallet status', type: WalletStatusResponseDto })
252+
@ApiResponse({ status: 404, description: 'Checkout not found' })
241253
getWalletStatus(@Param('id') checkoutId: string) {
242254
const checkout = this.subscriptionsService.getCheckout(checkoutId);
243255
return this.subscriptionsService.getWalletStatus(checkout.fanAddress);
244256
}
245257

246258
@Get('checkout/:id/preview')
247-
@ApiOperation({ summary: 'Get transaction preview for a checkout session' })
259+
@ApiOperation({ summary: 'Get transaction preview for a checkout session', description: 'Returns a preview of the Stellar transaction including from/to addresses, asset, amount, fee, and memo.' })
248260
@ApiParam({ name: 'id', description: 'Checkout session ID' })
249-
@ApiResponse({ status: 200, description: 'Transaction preview' })
261+
@ApiResponse({ status: 200, description: 'Transaction preview', type: TransactionPreviewResponseDto })
262+
@ApiResponse({ status: 404, description: 'Checkout not found' })
250263
getTransactionPreview(@Param('id') checkoutId: string) {
251264
return this.subscriptionsService.getTransactionPreview(checkoutId);
252265
}
@@ -256,10 +269,11 @@ export class SubscriptionsController {
256269
@ApiOperation({ summary: 'Validate fan wallet balance for a checkout session' })
257270
@ApiResponse({ status: 429, description: 'Too many requests' })
258271
@ApiParam({ name: 'id', description: 'Checkout session ID' })
259-
@ApiResponse({ status: 200, description: 'Balance validation result' })
272+
@ApiResponse({ status: 200, description: 'Balance validation result', type: ValidateBalanceResponseDto })
273+
@ApiResponse({ status: 404, description: 'Checkout not found' })
260274
validateBalance(
261275
@Param('id') checkoutId: string,
262-
@Body() body: { assetCode: string; amount: string },
276+
@Body() body: ValidateBalanceDto,
263277
) {
264278
const checkout = this.subscriptionsService.getCheckout(checkoutId);
265279
return this.subscriptionsService.validateBalance(
@@ -274,10 +288,12 @@ export class SubscriptionsController {
274288
@ApiOperation({ summary: 'Confirm a subscription checkout' })
275289
@ApiResponse({ status: 429, description: 'Too many requests' })
276290
@ApiParam({ name: 'id', description: 'Checkout session ID' })
277-
@ApiResponse({ status: 200, description: 'Subscription confirmed' })
291+
@ApiResponse({ status: 200, description: 'Subscription confirmed', type: ConfirmSubscriptionResponseDto })
292+
@ApiResponse({ status: 400, description: 'Checkout expired' })
293+
@ApiResponse({ status: 404, description: 'Checkout not found' })
278294
confirmSubscription(
279295
@Param('id') checkoutId: string,
280-
@Body() body: { txHash?: string },
296+
@Body() body: ConfirmSubscriptionDto,
281297
) {
282298
return this.subscriptionsService.confirmSubscription(checkoutId, body.txHash);
283299
}
@@ -288,9 +304,10 @@ export class SubscriptionsController {
288304
@ApiResponse({ status: 429, description: 'Too many requests' })
289305
@ApiParam({ name: 'id', description: 'Checkout session ID' })
290306
@ApiResponse({ status: 200, description: 'Checkout marked as failed' })
307+
@ApiResponse({ status: 404, description: 'Checkout not found' })
291308
failCheckout(
292309
@Param('id') checkoutId: string,
293-
@Body() body: { error: string; rejected?: boolean },
310+
@Body() body: FailCheckoutDto,
294311
) {
295312
return this.subscriptionsService.failCheckout(
296313
checkoutId,
@@ -304,8 +321,9 @@ export class SubscriptionsController {
304321
@ApiOperation({ summary: 'Cancel a subscription' })
305322
@ApiResponse({ status: 429, description: 'Too many requests' })
306323
@ApiResponse({ status: 200, description: 'Subscription cancelled' })
324+
@ApiResponse({ status: 404, description: 'Subscription not found' })
307325
cancelSubscription(
308-
@Body() body: { fanAddress: string; creatorAddress: string },
326+
@Body() body: CancelSubscriptionDto,
309327
) {
310328
return this.subscriptionsService.cancelSubscription(
311329
body.fanAddress,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { SubscriptionsController } from './subscriptions.controller';
2+
3+
describe('SubscriptionsController – Swagger/OpenAPI documentation', () => {
4+
const controllerPrototype = SubscriptionsController.prototype;
5+
6+
const endpoints = [
7+
'getFanCreatorSubscriptionState',
8+
'checkSubscription',
9+
'listSubscriptions',
10+
'listMySubscriptions',
11+
'listCreatorSubscribers',
12+
'createCheckout',
13+
'getCheckout',
14+
'getPlanSummary',
15+
'getPriceBreakdown',
16+
'getWalletStatus',
17+
'getTransactionPreview',
18+
'validateBalance',
19+
'confirmSubscription',
20+
'failCheckout',
21+
'cancelSubscription',
22+
];
23+
24+
it.each(endpoints)('%s has ApiOperation metadata', (method) => {
25+
const metadata = Reflect.getMetadata(
26+
'swagger/apiOperation',
27+
controllerPrototype[method],
28+
);
29+
expect(metadata).toBeDefined();
30+
expect(metadata.summary).toBeDefined();
31+
expect(metadata.summary.length).toBeGreaterThan(0);
32+
});
33+
34+
it.each(endpoints)('%s has at least one ApiResponse metadata', (method) => {
35+
const metadata = Reflect.getMetadata(
36+
'swagger/apiResponse',
37+
controllerPrototype[method],
38+
);
39+
expect(metadata).toBeDefined();
40+
expect(Object.keys(metadata).length).toBeGreaterThan(0);
41+
});
42+
43+
it('controller class has ApiTags metadata', () => {
44+
const tags = Reflect.getMetadata('swagger/apiUseTags', SubscriptionsController);
45+
expect(tags).toContain('subscriptions');
46+
});
47+
48+
const writeEndpoints = [
49+
'createCheckout',
50+
'validateBalance',
51+
'confirmSubscription',
52+
'failCheckout',
53+
'cancelSubscription',
54+
];
55+
56+
it.each(writeEndpoints)('%s documents 429 rate-limit response', (method) => {
57+
const metadata = Reflect.getMetadata(
58+
'swagger/apiResponse',
59+
controllerPrototype[method],
60+
);
61+
expect(metadata['429']).toBeDefined();
62+
expect(metadata['429'].description).toMatch(/too many requests/i);
63+
});
64+
65+
const paramEndpoints = [
66+
'getCheckout',
67+
'getPlanSummary',
68+
'getPriceBreakdown',
69+
'getWalletStatus',
70+
'getTransactionPreview',
71+
'validateBalance',
72+
'confirmSubscription',
73+
'failCheckout',
74+
];
75+
76+
it.each(paramEndpoints)('%s has ApiParam metadata for checkout id', (method) => {
77+
const params = Reflect.getMetadata(
78+
'swagger/apiParameters',
79+
controllerPrototype[method],
80+
);
81+
expect(params).toBeDefined();
82+
const idParam = params.find((p: { name: string }) => p.name === 'id');
83+
expect(idParam).toBeDefined();
84+
});
85+
});

0 commit comments

Comments
 (0)