Skip to content

Commit a7523a9

Browse files
feat(wallet-backend): integrate stripe into (#1967)
* feat(wallet-backend): initial stripe integration * feat(stripe): implement webhook handling and validation for Stripe events - Added StripeService and StripeController to handle payment intent events. - Introduced validation schemas for webhook requests using Zod. - Implemented methods to process payment intent succeeded, failed, and canceled events. - Updated createContainer to include StripeService. - Added tests for StripeController and StripeService to ensure correct handling of webhooks. * refactor(stripe): remove commented-out validation code in controller and validation files - Cleaned up the controller and validation files by removing unused commented-out code. - Improved code readability and maintainability. * refactor(tests): remove comments * refactor(stripe): clean up code formatting * feat(stripe): introduce webhook signature validation * refactor(stripe): improve code formatting and readability in webhook handling
1 parent 67a8b57 commit a7523a9

12 files changed

Lines changed: 724 additions & 5 deletions

File tree

docker/dev/docker-compose.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ services:
7171
GATEHUB_CARD_PP_PREFIX: ${GATEHUB_CARD_PP_PREFIX}
7272
CARD_DATA_HREF: ${CARD_DATA_HREF}
7373
CARD_PIN_HREF: ${CARD_PIN_HREF}
74+
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
75+
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
7476
restart: always
7577
networks:
7678
- testnet
@@ -241,8 +243,8 @@ services:
241243
DATA_FILE_EXISTS="$$?"
242244
set -e
243245
echo $$DATA_FILE_EXISTS
244-
if [ "$$DATA_FILE_EXISTS" != 0 ]; then
245-
./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 $$DATA_FILE;
246+
if [ "$$DATA_FILE_EXISTS" != 0 ]; then
247+
./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 $$DATA_FILE;
246248
fi
247249
hostname -i
248250
ls /var/lib/tigerbeetle

docker/prod/docker-compose.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ services:
7575
GATEHUB_CARD_PRODUCT_CODE: ${WALLET_BACKEND_GATEHUB_CARD_PRODUCT_CODE}
7676
GATEHUB_NAME_ON_CARD: ${WALLET_BACKEND_GATEHUB_NAME_ON_CARD}
7777
GATEHUB_CARD_PP_PREFIX: ${WALLET_BACKEND_GATEHUB_CARD_PP_PREFIX}
78+
STRIPE_SECRET_KEY: ${WALLET_BACKEND_STRIPE_SECRET_KEY}
79+
STRIPE_WEBHOOK_SECRET: ${WALLET_BACKEND_STRIPE_WEBHOOK_SECRET}
7880
networks:
7981
- testnet
8082
ports:
@@ -262,8 +264,8 @@ services:
262264
DATA_FILE_EXISTS="$$?"
263265
set -e
264266
echo $$DATA_FILE_EXISTS
265-
if [ "$$DATA_FILE_EXISTS" != 0 ]; then
266-
./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 $$DATA_FILE;
267+
if [ "$$DATA_FILE_EXISTS" != 0 ]; then
268+
./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 $$DATA_FILE;
267269
fi
268270
hostname -i
269271
ls /var/lib/tigerbeetle

packages/wallet/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"randexp": "^0.5.3",
3737
"rate-limiter-flexible": "^5.0.5",
3838
"socket.io": "^4.8.1",
39+
"stripe": "17.4.0",
3940
"uuid": "^10.0.0",
4041
"winston": "^3.17.0",
4142
"zod": "^3.23.8"

packages/wallet/backend/src/app.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export class App {
156156
const grantController = this.container.resolve('grantController')
157157
const accountController = this.container.resolve('accountController')
158158
const rafikiController = this.container.resolve('rafikiController')
159+
const stripeController = this.container.resolve('stripeController')
159160
const gateHubController = this.container.resolve('gateHubController')
160161
const cardController = this.container.resolve('cardController')
161162

@@ -171,6 +172,14 @@ export class App {
171172
)
172173

173174
app.use(helmet())
175+
176+
// Stripe webhook signature validation requires raw body, parsing is done afterwards
177+
app.post(
178+
'/stripe-webhooks',
179+
stripeController.webhookMiddleware,
180+
stripeController.onWebHook
181+
)
182+
174183
app.use(express.json())
175184
app.use(express.urlencoded({ extended: true, limit: '25mb' }))
176185
app.use(withSession)

packages/wallet/backend/src/config/env.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ const envSchema = z.object({
4848
.default('false')
4949
.transform((value) => value === 'true'),
5050
CARD_DATA_HREF: z.string().default('UPDATEME'),
51-
CARD_PIN_HREF: z.string().default('UPDATEME')
51+
CARD_PIN_HREF: z.string().default('UPDATEME'),
52+
STRIPE_SECRET_KEY: z.string().default('STRIPE_SECRET_KEY'),
53+
STRIPE_WEBHOOK_SECRET: z.string().default('STRIPE_WEBHOOK_SECRET')
5254
})
5355

5456
export type Env = z.infer<typeof envSchema>

packages/wallet/backend/src/createContainer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import { GateHubClient } from '@/gatehub/client'
5353
import { GateHubService } from '@/gatehub/service'
5454
import { CardController } from './card/controller'
5555
import { CardService } from './card/service'
56+
import { StripeController } from './stripe-integration/controller'
57+
import { StripeService } from './stripe-integration/service'
5658

5759
export interface Cradle {
5860
env: Env
@@ -75,6 +77,7 @@ export interface Cradle {
7577
incomingPaymentService: IncomingPaymentService
7678
outgoingPaymentService: OutgoingPaymentService
7779
rafikiService: RafikiService
80+
stripeService: StripeService
7881
quoteService: QuoteService
7982
grantService: GrantService
8083
socketService: SocketService
@@ -86,6 +89,7 @@ export interface Cradle {
8689
incomingPaymentController: IncomingPaymentController
8790
outgoingPaymentController: OutgoingPaymentController
8891
rafikiController: RafikiController
92+
stripeController: StripeController
8993
quoteController: QuoteController
9094
grantController: GrantController
9195
walletAddressController: WalletAddressController
@@ -130,6 +134,7 @@ export async function createContainer(
130134
incomingPaymentService: asClass(IncomingPaymentService).singleton(),
131135
outgoingPaymentService: asClass(OutgoingPaymentService).singleton(),
132136
rafikiService: asClassSingletonWithLogger(RafikiService, logger),
137+
stripeService: asClassSingletonWithLogger(StripeService, logger),
133138
quoteService: asClass(QuoteService).singleton(),
134139
grantService: asClass(GrantService).singleton(),
135140
socketService: asClassSingletonWithLogger(SocketService, logger),
@@ -142,6 +147,7 @@ export async function createContainer(
142147
incomingPaymentController: asClass(IncomingPaymentController).singleton(),
143148
outgoingPaymentController: asClass(OutgoingPaymentController).singleton(),
144149
rafikiController: asClassSingletonWithLogger(RafikiController, logger),
150+
stripeController: asClassSingletonWithLogger(StripeController, logger),
145151
quoteController: asClass(QuoteController).singleton(),
146152
grantController: asClass(GrantController).singleton(),
147153
walletAddressController: asClass(WalletAddressController).singleton(),
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { NextFunction, Request, Response } from 'express'
2+
import { Logger } from 'winston'
3+
import { StripeService } from './service'
4+
import { validate } from '../shared/validate'
5+
import { webhookBodySchema } from './validation'
6+
import { BadRequest } from '@shared/backend'
7+
import { env } from '../config/env'
8+
import express from 'express'
9+
import Stripe from 'stripe'
10+
11+
interface IStripeController {
12+
onWebHook: (req: Request, res: Response, next: NextFunction) => Promise<void>
13+
}
14+
15+
export class StripeController implements IStripeController {
16+
public webhookMiddleware: express.RequestHandler = express.raw({
17+
type: 'application/json'
18+
})
19+
private stripe: Stripe
20+
21+
constructor(
22+
private logger: Logger,
23+
private stripeService: StripeService
24+
) {
25+
this.stripe = new Stripe(env.STRIPE_SECRET_KEY)
26+
}
27+
28+
onWebHook = async (req: Request, res: Response, next: NextFunction) => {
29+
try {
30+
const signature = req.headers['stripe-signature']
31+
32+
if (!signature) {
33+
throw new BadRequest('Missing stripe-signature header')
34+
}
35+
36+
try {
37+
this.stripe.webhooks.constructEvent(
38+
req.body,
39+
signature,
40+
env.STRIPE_WEBHOOK_SECRET
41+
)
42+
} catch (err) {
43+
this.logger.error('Webhook signature verification failed', {
44+
error: err
45+
})
46+
throw new BadRequest('Invalid stripe webhook signature')
47+
}
48+
49+
const wh = await validate(webhookBodySchema, req)
50+
await this.stripeService.onWebHook(wh.body)
51+
res.status(200).send()
52+
} catch (e) {
53+
this.logger.error(
54+
`Webhook response error for stripe: ${(e as Error).message}`,
55+
req.body
56+
)
57+
next(e)
58+
}
59+
}
60+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { BadRequest } from '@shared/backend'
2+
import { Logger } from 'winston'
3+
import { GateHubClient } from '../gatehub/client'
4+
import { Env } from '../config/env'
5+
import { TransactionTypeEnum } from '../gatehub/consts'
6+
import { StripeWebhookType } from './validation'
7+
8+
export enum EventType {
9+
payment_intent_canceled = 'payment_intent.canceled',
10+
payment_intent_payment_failed = 'payment_intent.payment_failed',
11+
payment_intent_succeeded = 'payment_intent.succeeded'
12+
}
13+
14+
interface IStripeService {
15+
onWebHook: (wh: StripeWebhookType) => Promise<void>
16+
}
17+
18+
export class StripeService implements IStripeService {
19+
constructor(
20+
private env: Env,
21+
private logger: Logger,
22+
private gateHubClient: GateHubClient
23+
) {}
24+
25+
public async onWebHook(wh: StripeWebhookType): Promise<void> {
26+
this.logger.info(`received webhook of type : ${wh.type} for : ${wh.id}`)
27+
28+
switch (wh.type) {
29+
case EventType.payment_intent_succeeded:
30+
await this.handlePaymentIntentSucceeded(wh)
31+
break
32+
case EventType.payment_intent_payment_failed:
33+
await this.handlePaymentIntentFailed(wh)
34+
break
35+
case EventType.payment_intent_canceled:
36+
await this.handlePaymentIntentCanceled(wh)
37+
break
38+
}
39+
}
40+
41+
private async handlePaymentIntentSucceeded(wh: StripeWebhookType) {
42+
const paymentIntent = wh.data.object
43+
const metadata = paymentIntent.metadata
44+
const receiving_address: string = metadata.receiving_address
45+
const currency: string = paymentIntent.currency
46+
const amount: number = paymentIntent.amount
47+
48+
try {
49+
await this.gateHubClient.createTransaction({
50+
amount,
51+
vault_uuid: this.gateHubClient.getVaultUuid(currency),
52+
receiving_address,
53+
sending_address: this.env.GATEHUB_SETTLEMENT_WALLET_ADDRESS,
54+
type: TransactionTypeEnum.HOSTED,
55+
message: 'Stripe Transfer'
56+
})
57+
} catch (error) {
58+
this.logger.error('Error creating gatehub transaction', { error })
59+
throw new BadRequest('Failed to create transaction')
60+
}
61+
}
62+
63+
private async handlePaymentIntentFailed(wh: StripeWebhookType) {
64+
// No need to take action on the GateHub side as no funds were transferred
65+
const paymentIntent = wh.data.object
66+
const metadata = paymentIntent.metadata
67+
const receiving_address = metadata.receiving_address
68+
69+
this.logger.warn('Payment intent failed', {
70+
payment_intent_id: paymentIntent.id,
71+
receiving_address,
72+
error: paymentIntent.last_payment_error
73+
})
74+
}
75+
76+
private async handlePaymentIntentCanceled(wh: StripeWebhookType) {
77+
const paymentIntent = wh.data.object
78+
// No action needed on GateHub side as payment was canceled
79+
this.logger.info('Payment intent canceled', {
80+
payment_intent_id: paymentIntent.id,
81+
receiving_address: paymentIntent.metadata.receiving_address
82+
})
83+
}
84+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { z } from 'zod'
2+
import { EventType } from './service'
3+
4+
const paymentIntentSchema = z.object({
5+
id: z.string(),
6+
amount: z.number(),
7+
currency: z.string(),
8+
metadata: z.object({
9+
receiving_address: z.string({
10+
required_error: 'receiving_address is required in metadata'
11+
})
12+
}),
13+
last_payment_error: z.any().optional()
14+
})
15+
16+
const paymentIntentSucceededSchema = z.object({
17+
id: z.string({ required_error: 'id is required' }),
18+
type: z.literal(EventType.payment_intent_succeeded),
19+
data: z.object({
20+
object: paymentIntentSchema
21+
})
22+
})
23+
24+
const paymentIntentFailedSchema = z.object({
25+
id: z.string({ required_error: 'id is required' }),
26+
type: z.literal(EventType.payment_intent_payment_failed),
27+
data: z.object({
28+
object: paymentIntentSchema.extend({
29+
last_payment_error: z.any()
30+
})
31+
})
32+
})
33+
34+
const paymentIntentCanceledSchema = z.object({
35+
id: z.string({ required_error: 'id is required' }),
36+
type: z.literal(EventType.payment_intent_canceled),
37+
data: z.object({
38+
object: paymentIntentSchema
39+
})
40+
})
41+
42+
export const webhookSchema = z.discriminatedUnion('type', [
43+
paymentIntentSucceededSchema,
44+
paymentIntentFailedSchema,
45+
paymentIntentCanceledSchema
46+
])
47+
48+
export const webhookBodySchema = z.object({
49+
body: webhookSchema
50+
})
51+
52+
export type StripeWebhookType = z.infer<typeof webhookSchema>

0 commit comments

Comments
 (0)