Skip to content

Commit 8ce0e72

Browse files
committed
fix issue creating orders with stripe
1 parent 145d35f commit 8ce0e72

File tree

37 files changed

+612
-227
lines changed

37 files changed

+612
-227
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ [email protected]
2424
EMAIL_FROM_NAME=ProjectX Team
2525

2626
# PAYMENT SETTINGS
27+
# You can find your secret key in your Stripe account
2728
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
29+
# If you are testing your webhook locally with the Stripe CLI you
30+
# can find the endpoint's secret by running `stripe listen`
31+
# Otherwise, find your endpoint's secret in your webhook settings in the Developer Dashboard
2832
STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_signing_secret
2933

3034
# DEVELOPMENT ONLY

apps/auth/src/workflows/login.workflow.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export async function loginUserWorkflow(
7171
state.codeStatus = LoginWorkflowCodeStatus.SENT;
7272

7373
// Wait for user to verify code (human interaction)
74-
if (await condition(() => !!state.user, '10m'))
74+
await condition(() => !!state.user, '10m')
75+
7576
// Wait for all handlers to finish before checking the state
7677
await condition(allHandlersFinished);
7778
if (state.user) {

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Param,
1111
HttpStatus,
1212
HttpCode,
13+
Delete,
1314
} from '@nestjs/common';
1415
import {
1516
ApiBearerAuth,
@@ -19,7 +20,7 @@ import {
1920
ApiTags,
2021
} from '@nestjs/swagger';
2122
import { AuthenticatedUser, AuthUser, JwtAuthGuard } from '@projectx/core';
22-
import { CreateOrderDto } from '@projectx/models';
23+
import { CreateOrderDto, OrderStatusResponseDto } from '@projectx/models';
2324
import { Request } from 'express';
2425

2526
import { AppService } from './app.service';
@@ -56,13 +57,24 @@ export class AppController {
5657
})
5758
@ApiOkResponse({
5859
description: 'Returns the status of the order workflow',
60+
type: OrderStatusResponseDto,
5961
})
6062
@HttpCode(HttpStatus.OK)
6163
@Get(':referenceId')
6264
async getOrderStatus(@Param('referenceId') referenceId: string) {
6365
return this.appService.getOrderStatus(referenceId);
6466
}
6567

68+
@ApiBearerAuth()
69+
@UseGuards(JwtAuthGuard)
70+
@ApiOperation({
71+
summary: 'Cancel an order',
72+
})
73+
@Delete(':referenceId')
74+
async cancelOrder(@Param('referenceId') referenceId: string) {
75+
return this.appService.cancelOrder(referenceId);
76+
}
77+
6678
@Post('/webhook')
6779
async handleStripeWebhook(
6880
@Req() request: RawBodyRequest<Request>,

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ import {
1414
PaymentWebhookEvent,
1515
AuthUser,
1616
createOrderUpdate,
17+
getOrderStateQuery,
18+
cancelWorkflowSignal,
1719
} from '@projectx/core';
1820
import {
1921
WorkflowExecutionAlreadyStartedError,
2022
WorkflowIdConflictPolicy,
2123
} from '@temporalio/common';
24+
import { WithStartWorkflowOperation } from '@temporalio/client';
2225

2326
import { createOrder } from '../workflows/order.workflow';
24-
import { WithStartWorkflowOperation } from '@temporalio/client';
2527

2628
@Injectable()
2729
export class AppService {
@@ -114,10 +116,17 @@ export class AppService {
114116
}
115117

116118
const handle = this.clientService.client?.workflow.getHandle(workflowId);
117-
const state = await handle.query('getOrderState');
119+
const state = await handle.query(getOrderStateQuery);
118120
return state;
119121
}
120122

123+
async cancelOrder(referenceId: string) {
124+
this.logger.log(`cancelOrder(${referenceId}) - cancelling order`);
125+
const workflowId = this.getWorkflowIdByReferenceId(referenceId);
126+
const handle = this.clientService.client?.workflow.getHandle(workflowId);
127+
await handle.signal(cancelWorkflowSignal);
128+
}
129+
121130
async handleWebhook(payload: Buffer, signature: string) {
122131
this.logger.log('Processing webhook event');
123132
try {

apps/order/src/workflows/long-running.workflow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { continueAsNew, workflowInfo } from "@temporalio/workflow";
22

33
const MAX_NUMBER_OF_EVENTS = 10000;
4-
4+
// It's just an example of a long running workflow
55
export async function longRunningWorkflow(n: number): Promise<void> {
66
// Long-duration workflow
77
while (workflowInfo().historyLength < MAX_NUMBER_OF_EVENTS) {
Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,53 @@
11
/* eslint-disable @nx/enforce-module-boundaries */
22
import {
33
allHandlersFinished,
4+
ApplicationFailure,
45
ChildWorkflowHandle,
56
condition,
67
proxyActivities,
78
setHandler,
89
startChild,
10+
log,
911
} from '@temporalio/workflow';
1012

13+
// Typescript alias issue while importing files from other libraries from workflows.
1114
import {
1215
OrderProcessPaymentStatus,
1316
OrderWorkflowData,
1417
OrderWorkflowNonRetryableErrors,
15-
OrderWorkflowState,
16-
OrderWorkflowStatus,
1718
createOrderUpdate,
1819
getOrderStateQuery,
1920
getWorkflowIdByPaymentOrder,
2021
} from '../../../../libs/backend/core/src/lib/order/workflow.utils';
21-
import {
22-
cancelWorkflowSignal,
23-
} from '../../../../libs/backend/core/src/lib/workflows';
22+
import { cancelWorkflowSignal } from '../../../../libs/backend/core/src/lib/workflows';
23+
import type { OrderStatusResponseDto } from '../../../../libs/models/src/order/order.dto';
2424
import type { ActivitiesService } from '../main';
2525

26-
const { createOrder: createOrderActivity } = proxyActivities<ActivitiesService>({
27-
startToCloseTimeout: '5 seconds',
28-
retry: {
29-
initialInterval: '2s',
30-
maximumInterval: '10s',
31-
maximumAttempts: 10,
32-
backoffCoefficient: 1.5,
33-
nonRetryableErrorTypes: [OrderWorkflowNonRetryableErrors.UNKNOWN_ERROR],
34-
},
35-
});
26+
const { createOrder: createOrderActivity } = proxyActivities<ActivitiesService>(
27+
{
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+
}
37+
);
3638
import { processPayment } from './process-payment.workflow';
3739

38-
const initialState: OrderWorkflowState = {
39-
status: OrderWorkflowStatus.PENDING,
40+
export enum OrderStatus {
41+
Pending = 'Pending',
42+
Confirmed = 'Confirmed',
43+
Shipped = 'Shipped',
44+
Delivered = 'Delivered',
45+
Cancelled = 'Cancelled',
46+
Failed = 'Failed',
47+
}
48+
49+
const initialState: OrderStatusResponseDto = {
50+
status: OrderStatus.Pending,
4051
orderId: undefined,
4152
referenceId: '',
4253
clientSecret: undefined,
@@ -46,15 +57,27 @@ export async function createOrder(
4657
data: OrderWorkflowData,
4758
state = initialState
4859
): Promise<void> {
60+
state.referenceId = data.order.referenceId;
4961
// Define references to child workflows
5062
let processPaymentWorkflow: ChildWorkflowHandle<typeof processPayment>;
5163

5264
// Attach queries, signals and updates
5365
setHandler(getOrderStateQuery, () => state);
54-
setHandler(
55-
cancelWorkflowSignal,
56-
() => processPaymentWorkflow?.signal(cancelWorkflowSignal)
57-
);
66+
setHandler(cancelWorkflowSignal, () => {
67+
log.info('Requesting order cancellation');
68+
if (!state?.orderId) {
69+
throw ApplicationFailure.nonRetryable(
70+
OrderWorkflowNonRetryableErrors.CANCELLED,
71+
'Order cancelled'
72+
);
73+
}
74+
if (processPaymentWorkflow) {
75+
processPaymentWorkflow.signal(cancelWorkflowSignal);
76+
} else {
77+
log.error('The payment process has already finished, cannot cancel');
78+
}
79+
});
80+
// Create the order and the payment intent with the payment provider
5881
setHandler(createOrderUpdate, async () => {
5982
const { order, clientSecret } = await createOrderActivity(data);
6083
state.orderId = order.id;
@@ -63,25 +86,30 @@ export async function createOrder(
6386
return state;
6487
});
6588

66-
// Wait to create the order in the database
89+
// Wait the order to be ready to be processed
6790
await condition(() => !!state?.orderId);
68-
69-
// First step: Process payment
70-
if (state.status === OrderWorkflowStatus.PENDING) {
91+
92+
// First step - Process payment
93+
if (state.status === OrderStatus.Pending) {
7194
processPaymentWorkflow = await startChild(processPayment, {
7295
args: [data],
7396
workflowId: getWorkflowIdByPaymentOrder(state.referenceId),
7497
});
7598
const processPaymentResult = await processPaymentWorkflow.result();
76-
if (processPaymentResult.status === OrderProcessPaymentStatus.SUCCESS) {
77-
state.status = OrderWorkflowStatus.PAYMENT_COMPLETED;
78-
} else {
79-
state.status = OrderWorkflowStatus.FAILED;
80-
return;
99+
if (processPaymentResult.status !== OrderProcessPaymentStatus.SUCCESS) {
100+
state.status = OrderStatus.Failed;
101+
// TODO: Send email to the user
102+
throw ApplicationFailure.nonRetryable(
103+
OrderWorkflowNonRetryableErrors.UNKNOWN_ERROR,
104+
'Payment failed'
105+
);
81106
}
82107
processPaymentWorkflow = undefined;
83-
state.status = OrderWorkflowStatus.COMPLETED;
108+
state.status = OrderStatus.Confirmed;
84109
}
110+
111+
// TODO: Second step - Ship the order
112+
85113
// Wait for all handlers to finish before workflow completion
86114
await condition(allHandlersFinished);
87115
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const sessionSecret = getRequiredServerEnvVar(
77
'SESSION_SECRET',
88
'MY_SECRET_KEY'
99
);
10+
export const stripeSecretKey = getRequiredServerEnvVar('STRIPE_SECRET_KEY');
1011
export const authAPIUrl = getRequiredServerEnvVar('AUTH_API_URL', 'http://localhost:8081');
1112
export const orderAPIUrl = getRequiredServerEnvVar('ORDER_API_URL', 'http://localhost:8082');
1213
export const productAPIUrl = getRequiredServerEnvVar('PRODUCT_API_URL', 'http://localhost:8083');

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ const trustedDomains = [appDomains, localDomains].filter(Boolean).join(' ');
1212

1313
export const defaultSrc = replaceNewLinesWithSpaces(`
1414
${trustedDomains}
15+
https://*.stripe.com
1516
`);
1617

1718
export const scriptSrc = replaceNewLinesWithSpaces(`
1819
${defaultSrc}
20+
1921
`);
2022

2123
export const frameSrc = replaceNewLinesWithSpaces(`
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Elements } from '@stripe/react-stripe-js';
2+
import type { Appearance } from '@stripe/stripe-js';
3+
import { Stripe, loadStripe } from '@stripe/stripe-js';
4+
import { motion } from 'framer-motion';
5+
import { useEffect, useState } from 'react';
6+
7+
import { CheckoutForm } from './checkout/CheckoutForm';
8+
import { CompletePage } from './checkout/CompletePage';
9+
import { stripeSecretKey } from '~/config/app.config.server';
10+
11+
// Make sure to call loadStripe outside of a component's render to avoid
12+
// recreating the Stripe object on every render.
13+
// This is your test publishable API key.
14+
const stripePromise = loadStripe(stripeSecretKey);
15+
16+
interface CheckoutPageProps {
17+
clientSecret?: string;
18+
}
19+
20+
const appearance: Appearance = {
21+
theme: 'stripe',
22+
variables: {
23+
colorPrimary: '#9333ea', // purple-600
24+
colorBackground: '#ffffff',
25+
colorText: '#111827', // gray-900
26+
colorDanger: '#dc2626', // red-600
27+
fontFamily: 'system-ui, sans-serif',
28+
spacingUnit: '4px',
29+
borderRadius: '8px',
30+
},
31+
rules: {
32+
'.Input': {
33+
backgroundColor: '#f3f4f6', // gray-100
34+
color: '#111827', // gray-900
35+
},
36+
'.Input--invalid': {
37+
color: '#dc2626', // red-600
38+
},
39+
'.Label': {
40+
color: '#374151', // gray-700
41+
},
42+
'.Tab': {
43+
backgroundColor: '#f3f4f6', // gray-100
44+
color: '#374151', // gray-700
45+
},
46+
'.Tab--selected': {
47+
backgroundColor: '#9333ea', // purple-600
48+
color: '#ffffff',
49+
},
50+
},
51+
};
52+
53+
export const CheckoutPage = ({ clientSecret }: CheckoutPageProps) => {
54+
const [confirmed, setConfirmed] = useState(false);
55+
const [stripe, setStripe] = useState<Stripe | null>(null);
56+
57+
useEffect(() => {
58+
// Initialize Stripe
59+
stripePromise.then(setStripe);
60+
}, []);
61+
62+
if (!clientSecret) {
63+
return (
64+
<div className="flex-grow flex items-center justify-center p-4">
65+
<motion.div
66+
initial={{ opacity: 0, y: -20 }}
67+
animate={{ opacity: 1, y: 0 }}
68+
transition={{ duration: 0.5 }}
69+
className="w-full max-w-md bg-white dark:bg-gray-800 shadow-xl rounded-lg p-8"
70+
>
71+
<div className="text-center">
72+
<p className="text-base font-semibold text-purple-600 dark:text-purple-400">404</p>
73+
<h1 className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">Payment not found</h1>
74+
<p className="mt-2 text-base text-gray-500 dark:text-gray-400">
75+
The payment session you're looking for doesn't exist or has expired.
76+
</p>
77+
</div>
78+
</motion.div>
79+
</div>
80+
);
81+
}
82+
83+
const options = {
84+
clientSecret,
85+
appearance,
86+
};
87+
88+
return (
89+
<div className="flex-grow flex items-center justify-center p-4">
90+
<motion.div
91+
initial={{ opacity: 0, y: -20 }}
92+
animate={{ opacity: 1, y: 0 }}
93+
transition={{ duration: 0.5 }}
94+
className="w-full max-w-3xl bg-white dark:bg-gray-800 shadow-xl rounded-lg p-8"
95+
>
96+
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">Complete Payment</h2>
97+
<div className="space-y-8">
98+
<Elements options={options} stripe={stripePromise}>
99+
{confirmed ? <CompletePage /> : <CheckoutForm onConfirmed={() => setConfirmed(true)} />}
100+
</Elements>
101+
</div>
102+
</motion.div>
103+
</div>
104+
);
105+
};

0 commit comments

Comments
 (0)