@@ -22,6 +22,7 @@ import {
2222 DatabaseInsertError ,
2323 InternalServerError ,
2424 UnauthenticatedError ,
25+ ValidationError ,
2526} from "common/errors/index.js" ;
2627import { Modules } from "common/modules.js" ;
2728import { AppRoles } from "common/roles.js" ;
@@ -32,8 +33,17 @@ import {
3233} from "common/types/stripe.js" ;
3334import { FastifyPluginAsync } from "fastify" ;
3435import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi" ;
36+ import stripe , { Stripe } from "stripe" ;
37+ import rawbody from "fastify-raw-body" ;
38+ import { AvailableSQSFunctions , SQSPayload } from "common/types/sqsMessage.js" ;
39+ import { SendMessageCommand , SQSClient } from "@aws-sdk/client-sqs" ;
3540
3641const stripeRoutes : FastifyPluginAsync = async ( fastify , _options ) => {
42+ await fastify . register ( rawbody , {
43+ field : "rawBody" ,
44+ global : false ,
45+ runFirst : true ,
46+ } ) ;
3747 fastify . withTypeProvider < FastifyZodOpenApiTypeProvider > ( ) . get (
3848 "/paymentLinks" ,
3949 {
@@ -165,6 +175,156 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
165175 reply . status ( 201 ) . send ( { id : linkId , link : url } ) ;
166176 } ,
167177 ) ;
178+ fastify . post (
179+ "/webhook" ,
180+ {
181+ config : { rawBody : true } ,
182+ schema : withTags ( [ "Stripe" ] , {
183+ summary :
184+ "Stripe webhook handler to track when Stripe payment links are used." ,
185+ hide : true ,
186+ } ) ,
187+ } ,
188+ async ( request , reply ) => {
189+ let event : Stripe . Event ;
190+ if ( ! request . rawBody ) {
191+ throw new ValidationError ( { message : "Could not get raw body." } ) ;
192+ }
193+ try {
194+ const sig = request . headers [ "stripe-signature" ] ;
195+ if ( ! sig || typeof sig !== "string" ) {
196+ throw new Error ( "Missing or invalid Stripe signature" ) ;
197+ }
198+ const secretApiConfig =
199+ ( await getSecretValue (
200+ fastify . secretsManagerClient ,
201+ genericConfig . ConfigSecretName ,
202+ ) ) || { } ;
203+ if ( ! secretApiConfig ) {
204+ throw new InternalServerError ( {
205+ message : "Could not connect to Stripe." ,
206+ } ) ;
207+ }
208+ event = stripe . webhooks . constructEvent (
209+ request . rawBody ,
210+ sig ,
211+ secretApiConfig . stripe_links_endpoint_secret as string ,
212+ ) ;
213+ } catch ( err : unknown ) {
214+ if ( err instanceof BaseError ) {
215+ throw err ;
216+ }
217+ throw new ValidationError ( {
218+ message : "Stripe webhook could not be validated." ,
219+ } ) ;
220+ }
221+ switch ( event . type ) {
222+ case "checkout.session.completed" :
223+ if ( event . data . object . payment_link ) {
224+ const eventId = event . id ;
225+ const paymentAmount = event . data . object . amount_total ;
226+ const paymentCurrency = event . data . object . currency ;
227+ const { email, name } = event . data . object . customer_details || {
228+ email : null ,
229+ name : null ,
230+ } ;
231+ const paymentLinkId = event . data . object . payment_link . toString ( ) ;
232+ if ( ! paymentLinkId || ! paymentCurrency || ! paymentAmount ) {
233+ request . log . info ( "Missing required fields." ) ;
234+ return reply
235+ . code ( 200 )
236+ . send ( { handled : false , requestId : request . id } ) ;
237+ }
238+ const response = await fastify . dynamoClient . send (
239+ new QueryCommand ( {
240+ TableName : genericConfig . StripeLinksDynamoTableName ,
241+ IndexName : "LinkIdIndex" ,
242+ KeyConditionExpression : "linkId = :linkId" ,
243+ ExpressionAttributeValues : {
244+ ":linkId" : { S : paymentLinkId } ,
245+ } ,
246+ } ) ,
247+ ) ;
248+ if ( ! response ) {
249+ throw new DatabaseFetchError ( {
250+ message : "Could not check for payment link in table." ,
251+ } ) ;
252+ }
253+ if ( ! response . Items || response . Items ?. length !== 1 ) {
254+ return reply . status ( 200 ) . send ( {
255+ handled : false ,
256+ requestId : request . id ,
257+ } ) ;
258+ }
259+ const unmarshalledEntry = unmarshall ( response . Items [ 0 ] ) as {
260+ userId : string ;
261+ invoiceId : string ;
262+ } ;
263+ if ( ! unmarshalledEntry . userId || ! unmarshalledEntry . invoiceId ) {
264+ return reply . status ( 200 ) . send ( {
265+ handled : false ,
266+ requestId : request . id ,
267+ } ) ;
268+ }
269+ const withCurrency = new Intl . NumberFormat ( "en-US" , {
270+ style : "currency" ,
271+ currency : paymentCurrency . toUpperCase ( ) ,
272+ } )
273+ . formatToParts ( paymentAmount / 100 )
274+ . map ( ( val ) => val . value )
275+ . join ( "" ) ;
276+ request . log . info (
277+ `Registered payment of ${ withCurrency } by ${ name } (${ email } ) for payment link ${ paymentLinkId } invoice ID ${ unmarshalledEntry . invoiceId } ).` ,
278+ ) ;
279+ if ( unmarshalledEntry . userId . includes ( "@" ) ) {
280+ request . log . info (
281+ `Sending email to ${ unmarshalledEntry . userId } ...` ,
282+ ) ;
283+ const sqsPayload : SQSPayload < AvailableSQSFunctions . EmailNotifications > =
284+ {
285+ function : AvailableSQSFunctions . EmailNotifications ,
286+ metadata : {
287+ initiator : eventId ,
288+ reqId : request . id ,
289+ } ,
290+ payload : {
291+ to : [ unmarshalledEntry . userId ] ,
292+ subject : `Payment Recieved for Invoice ${ unmarshalledEntry . invoiceId } ` ,
293+ content :
`Received payment of ${ withCurrency } by ${ name } (${ email } ) for Invoice ${ unmarshalledEntry . invoiceId } . Please contact [email protected] with any questions.` , 294+ } ,
295+ } ;
296+ if ( ! fastify . sqsClient ) {
297+ fastify . sqsClient = new SQSClient ( {
298+ region : genericConfig . AwsRegion ,
299+ } ) ;
300+ }
301+ const result = await fastify . sqsClient . send (
302+ new SendMessageCommand ( {
303+ QueueUrl : fastify . environmentConfig . SqsQueueUrl ,
304+ MessageBody : JSON . stringify ( sqsPayload ) ,
305+ } ) ,
306+ ) ;
307+ return reply . status ( 200 ) . send ( {
308+ handled : true ,
309+ requestId : request . id ,
310+ queueId : result . MessageId ,
311+ } ) ;
312+ }
313+ return reply . status ( 200 ) . send ( {
314+ handled : true ,
315+ requestId : request . id ,
316+ } ) ;
317+ }
318+ return reply
319+ . code ( 200 )
320+ . send ( { handled : false , requestId : request . id } ) ;
321+
322+ default :
323+ request . log . warn ( `Unhandled event type: ${ event . type } ` ) ;
324+ }
325+ return reply . code ( 200 ) . send ( { handled : false , requestId : request . id } ) ;
326+ } ,
327+ ) ;
168328} ;
169329
170330export default stripeRoutes ;
0 commit comments