Skip to content

Commit b3472a4

Browse files
committed
fix payment processing workflow
1 parent 16708ed commit b3472a4

File tree

25 files changed

+212
-139
lines changed

25 files changed

+212
-139
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ EMAIL_FROM_NAME=ProjectX Team
2525

2626
# PAYMENT SETTINGS
2727
# You can find your secret key in your Stripe account
28+
STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key
2829
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
2930
# If you are testing your webhook locally with the Stripe CLI you
3031
# can find the endpoint's secret by running `stripe listen`

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@ docker-compose down --volumes
220220
- [Automatic fulfillment Orders](https://docs.stripe.com/checkout/fulfillment)
221221
- [Interactive webhook endpoint builder](https://docs.stripe.com/webhooks/quickstart)
222222
- [Trigger webhook events with the Stripe CLI](https://docs.stripe.com/stripe-cli/triggers)
223+
- [Testing cards](https://docs.stripe.com/testing#cards)
224+
- Stripe commands for testing webhooks:
225+
```bash
226+
brew install stripe/stripe-cli/stripe
227+
stripe login --api-key ...
228+
stripe trigger payment_intent.succeeded
229+
stripe listen --forward-to localhost:8081/order/webhook
230+
```
223231

224232
## Supporting 🍻
225233
I believe in Unicorns 🦄

apps/order/src/app/activities/activities.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ export class ActivitiesService {
1616
async reportPaymentFailed(orderId: number) {
1717
return this.orderService.reportPaymentFailed(orderId);
1818
}
19+
20+
async reportPaymentConfirmed(orderId: number) {
21+
return this.orderService.reportPaymentConfirmed(orderId);
22+
}
1923
}

apps/order/src/app/app.controller.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,25 @@ import {
44
Get,
55
Post,
66
Headers,
7-
RawBodyRequest,
8-
Req,
97
UseGuards,
108
Param,
119
HttpStatus,
1210
HttpCode,
1311
Delete,
12+
Req,
13+
RawBodyRequest,
1414
} from '@nestjs/common';
1515
import {
1616
ApiBearerAuth,
1717
ApiOkResponse,
1818
ApiOperation,
1919
ApiParam,
2020
ApiTags,
21+
ApiHeader,
22+
ApiResponse,
2123
} from '@nestjs/swagger';
2224
import { AuthenticatedUser, AuthUser, JwtAuthGuard } from '@projectx/core';
2325
import { CreateOrderDto, OrderStatusResponseDto } from '@projectx/models';
24-
import { Request } from 'express';
2526

2627
import { AppService } from './app.service';
2728

@@ -75,11 +76,30 @@ export class AppController {
7576
return this.appService.cancelOrder(referenceId);
7677
}
7778

79+
@ApiOperation({
80+
summary: 'Handle Stripe webhook events',
81+
description: 'Endpoint for receiving webhook events from Stripe for payment processing',
82+
})
83+
@ApiHeader({
84+
name: 'stripe-signature',
85+
description: 'Stripe signature for webhook event verification',
86+
required: true,
87+
})
88+
@ApiResponse({
89+
status: 200,
90+
description: 'Webhook event processed successfully',
91+
})
92+
@ApiResponse({
93+
status: 400,
94+
description: 'Invalid payload or signature',
95+
})
96+
@HttpCode(HttpStatus.OK)
7897
@Post('/webhook')
7998
async handleStripeWebhook(
8099
@Req() request: RawBodyRequest<Request>,
81100
@Headers('stripe-signature') signature: string
82101
) {
102+
// Validate and process the webhook
83103
return this.appService.handleWebhook(request.rawBody, signature);
84104
}
85105
}

apps/order/src/app/app.service.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
1+
import {
2+
BadRequestException,
3+
HttpException,
4+
HttpStatus,
5+
Injectable,
6+
Logger,
7+
} from '@nestjs/common';
28
import { ConfigService } from '@nestjs/config';
39
import { CreateOrderDto } from '@projectx/models';
410
import {
@@ -87,9 +93,9 @@ export class AppService {
8793
HttpStatus.CONFLICT
8894
);
8995
} else {
90-
throw new HttpException(
96+
this.logger.error(`createOrder(${user.email}) - Error creating order`, error);
97+
throw new BadRequestException(
9198
'Error creating order',
92-
HttpStatus.INTERNAL_SERVER_ERROR,
9399
{
94100
cause: error,
95101
}
@@ -111,7 +117,7 @@ export class AppService {
111117
throw new HttpException('No active order found', HttpStatus.NOT_FOUND);
112118
}
113119

114-
if (Date.now() - description.startTime.getMilliseconds() >= WORKFLOW_TTL) {
120+
if (Date.now() - description.startTime.getTime() >= WORKFLOW_TTL) {
115121
throw new HttpException('Order has expired', HttpStatus.GONE);
116122
}
117123

@@ -127,17 +133,25 @@ export class AppService {
127133
await handle.signal(cancelWorkflowSignal);
128134
}
129135

130-
async handleWebhook(payload: Buffer, signature: string) {
131-
this.logger.log('Processing webhook event');
136+
async handleWebhook(payload: string | Buffer, signature: string) {
137+
if (!payload || !signature) {
138+
this.logger.error(`handleWebhook(${signature}) - No payload received`);
139+
throw new BadRequestException('No payload received');
140+
}
141+
this.logger.log(`handleWebhook(${signature}) - Processing webhook event`);
132142
try {
133143
// Verify and construct the webhook event
134-
const event = await this.stripeService.constructWebhookEvent(
144+
const event = this.stripeService.constructWebhookEvent(
135145
payload,
136146
signature
137147
);
138148

139149
// Extract payment intent data
140150
const paymentIntent = this.stripeService.handleWebhookEvent(event);
151+
if (!paymentIntent?.metadata) {
152+
this.logger.error(`handleWebhook(${signature}) - Unhandled event type: ${event.type}`);
153+
return { received: true };
154+
}
141155
const { userId, referenceId } = paymentIntent.metadata;
142156

143157
if (!userId || !referenceId) {
@@ -174,8 +188,10 @@ export class AppService {
174188
// Return true to indicate the webhook was received
175189
return { received: true };
176190
} catch (err) {
177-
this.logger.error('Webhook Error:', err.message);
178-
throw err;
191+
this.logger.error(`handleWebhook(${signature}) - Webhook Error: ${err.message}`);
192+
throw new BadRequestException('Webhook Error', {
193+
cause: err,
194+
});
179195
}
180196
}
181197
}

apps/order/src/app/order/order.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,14 @@ export class OrderService {
4646
// TODO: Send email notification to user about payment failure
4747
return updatedOrder;
4848
}
49+
50+
async reportPaymentConfirmed(orderId: number) {
51+
this.logger.log(`reportPaymentConfirmed(${orderId})`);
52+
const updatedOrder = await this.orderRepositoryService.updateOrderStatus(
53+
orderId,
54+
OrderStatus.Confirmed
55+
);
56+
// TODO: Send email notification to user about payment confirmation
57+
return updatedOrder;
58+
}
4959
}

apps/order/src/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { AppModule } from './app/app.module';
66
// Export activities to be used in workflows
77
export * from './app/activities/activities.service';
88

9-
bootstrapApp(AppModule).catch((err) => {
9+
// Enable raw body parsing for webhook events
10+
bootstrapApp(AppModule, { rawBody: true }).catch((err) => {
1011
Logger.error(
1112
`⚠️ Application failed to start: ${err}`
1213
)

apps/order/src/workflows/order.workflow.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,28 @@ import {
1818
createOrderUpdate,
1919
getOrderStateQuery,
2020
getWorkflowIdByPaymentOrder,
21+
paymentWebHookEventSignal,
2122
} from '../../../../libs/backend/core/src/lib/order/workflow.utils';
2223
import { cancelWorkflowSignal } from '../../../../libs/backend/core/src/lib/workflows';
2324
import type { OrderStatusResponseDto } from '../../../../libs/models/src/order/order.dto';
2425
import type { ActivitiesService } from '../main';
25-
26-
const { createOrder: createOrderActivity, reportPaymentFailed } =
27-
proxyActivities<ActivitiesService>({
28-
startToCloseTimeout: '5 seconds',
29-
retry: {
30-
initialInterval: '2s',
31-
maximumInterval: '10s',
32-
maximumAttempts: 10,
33-
backoffCoefficient: 1.5,
34-
nonRetryableErrorTypes: [OrderWorkflowNonRetryableErrors.UNKNOWN_ERROR],
35-
},
36-
});
3726
import { processPayment } from './process-payment.workflow';
3827

28+
const {
29+
createOrder: createOrderActivity,
30+
reportPaymentFailed,
31+
reportPaymentConfirmed,
32+
} = proxyActivities<ActivitiesService>({
33+
startToCloseTimeout: '5 seconds',
34+
retry: {
35+
initialInterval: '2s',
36+
maximumInterval: '10s',
37+
maximumAttempts: 10,
38+
backoffCoefficient: 1.5,
39+
nonRetryableErrorTypes: [OrderWorkflowNonRetryableErrors.UNKNOWN_ERROR],
40+
},
41+
});
42+
3943
export enum OrderStatus {
4044
Pending = 'Pending',
4145
Confirmed = 'Confirmed',
@@ -76,6 +80,9 @@ export async function createOrder(
7680
log.error('The payment process has already finished, cannot cancel');
7781
}
7882
});
83+
setHandler(paymentWebHookEventSignal, (e) =>
84+
processPaymentWorkflow?.signal(paymentWebHookEventSignal, e)
85+
);
7986
// Create the order and the payment intent with the payment provider
8087
setHandler(createOrderUpdate, async () => {
8188
const { order, clientSecret } = await createOrderActivity(data);
@@ -106,6 +113,7 @@ export async function createOrder(
106113
}
107114
processPaymentWorkflow = undefined;
108115
state.status = OrderStatus.Confirmed;
116+
await reportPaymentConfirmed(state.orderId);
109117
}
110118

111119
// TODO: Second step - Ship the order

apps/web/app/config/app.config.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const sessionSecret = getRequiredServerEnvVar(
77
'SESSION_SECRET',
88
'MY_SECRET_KEY'
99
);
10-
export const stripeSecretKey = getRequiredServerEnvVar('STRIPE_SECRET_KEY');
10+
export const stripePublishableKey = getRequiredServerEnvVar('STRIPE_PUBLISHABLE_KEY');
1111
export const authAPIUrl = getRequiredServerEnvVar('AUTH_API_URL', 'http://localhost:8081');
1212
export const orderAPIUrl = getRequiredServerEnvVar('ORDER_API_URL', 'http://localhost:8082');
1313
export const productAPIUrl = getRequiredServerEnvVar('PRODUCT_API_URL', 'http://localhost:8083');

apps/web/app/config/env.server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import { authAPIUrl, environment, orderAPIUrl, productAPIUrl } from './app.config.server';
1+
import {
2+
authAPIUrl,
3+
environment,
4+
orderAPIUrl,
5+
productAPIUrl,
6+
stripePublishableKey,
7+
} from './app.config.server';
28

39
export function getEnv() {
410
return {
511
NODE_ENV: environment,
612
AUTH_API_URL: authAPIUrl,
713
ORDER_API_URL: orderAPIUrl,
814
PRODUCT_API_URL: productAPIUrl,
15+
STRIPE_PUBLISHABLE_KEY: stripePublishableKey,
916
};
1017
}
1118

0 commit comments

Comments
 (0)