@@ -17,7 +17,11 @@ import {
17
17
StripeLinkCreateParams ,
18
18
} from "api/functions/stripe.js" ;
19
19
import { getSecretValue } from "api/plugins/auth.js" ;
20
- import { environmentConfig , genericConfig } from "common/config.js" ;
20
+ import {
21
+ environmentConfig ,
22
+ genericConfig ,
23
+ notificationRecipients ,
24
+ } from "common/config.js" ;
21
25
import {
22
26
BaseError ,
23
27
DatabaseFetchError ,
@@ -333,7 +337,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
333
337
} ) ;
334
338
}
335
339
switch ( event . type ) {
336
- case "checkout.session.completed " :
340
+ case "checkout.session.async_payment_failed " :
337
341
if ( event . data . object . payment_link ) {
338
342
const eventId = event . id ;
339
343
const paymentAmount = event . data . object . amount_total ;
@@ -391,93 +395,263 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
391
395
. formatToParts ( paymentAmount / 100 )
392
396
. map ( ( val ) => val . value )
393
397
. join ( "" ) ;
394
- request . log . info (
395
- `Registered payment of ${ withCurrency } by ${ name } (${ email } ) for payment link ${ paymentLinkId } invoice ID ${ unmarshalledEntry . invoiceId } ). Invoice was paid ${ paidInFull ? "in full." : "partially." } ` ,
396
- ) ;
397
- // Notify link owner of payment
398
+
399
+ // Notify link owner of failed payment
398
400
let queueId ;
399
- if ( unmarshalledEntry . userId . includes ( "@" ) ) {
401
+ if ( event . data . object . payment_status === "unpaid" ) {
400
402
request . log . info (
401
- `Sending email to ${ unmarshalledEntry . userId } .. .` ,
403
+ `Failed payment of ${ withCurrency } by ${ name } ( ${ email } ) for payment link ${ paymentLinkId } invoice ID ${ unmarshalledEntry . invoiceId } ) .` ,
402
404
) ;
403
- const sqsPayload : SQSPayload < AvailableSQSFunctions . EmailNotifications > =
404
- {
405
- function : AvailableSQSFunctions . EmailNotifications ,
406
- metadata : {
407
- initiator : eventId ,
408
- reqId : request . id ,
409
- } ,
410
- payload : {
411
- to : [ unmarshalledEntry . userId ] ,
412
- subject : `Payment Recieved for Invoice ${ unmarshalledEntry . invoiceId } ` ,
413
- content : `ACM @ UIUC has received ${ paidInFull ? "full" : "partial" } payment for Invoice ${ unmarshalledEntry . invoiceId } (${ withCurrency } paid by ${ name } , ${ email } ).\n\nPlease contact Officer Board with any questions.` ,
414
- callToActionButton : {
415
- name : "View Your Stripe Links" ,
416
- url : `${ fastify . environmentConfig . UserFacingUrl } /stripe` ,
405
+ if ( unmarshalledEntry . userId . includes ( "@" ) ) {
406
+ request . log . info (
407
+ `Sending email to ${ unmarshalledEntry . userId } ...` ,
408
+ ) ;
409
+ const sqsPayload : SQSPayload < AvailableSQSFunctions . EmailNotifications > =
410
+ {
411
+ function : AvailableSQSFunctions . EmailNotifications ,
412
+ metadata : {
413
+ initiator : eventId ,
414
+ reqId : request . id ,
417
415
} ,
418
- } ,
419
- } ;
420
- if ( ! fastify . sqsClient ) {
421
- fastify . sqsClient = new SQSClient ( {
422
- region : genericConfig . AwsRegion ,
423
- } ) ;
416
+ payload : {
417
+ to : [ unmarshalledEntry . userId ] ,
418
+ subject : `Payment Failed for Invoice ${ unmarshalledEntry . invoiceId } ` ,
419
+ content : `
420
+ A ${ paidInFull ? "full" : "partial" } payment for Invoice ${ unmarshalledEntry . invoiceId } (${ withCurrency } paid by ${ name } , ${ email } ) <b>has failed.</b>
421
+
422
+ Please ask the payee to try again, perhaps with a different payment method, or contact Officer Board.
423
+ ` ,
424
+ callToActionButton : {
425
+ name : "View Your Stripe Links" ,
426
+ url : `${ fastify . environmentConfig . UserFacingUrl } /stripe` ,
427
+ } ,
428
+ } ,
429
+ } ;
430
+ if ( ! fastify . sqsClient ) {
431
+ fastify . sqsClient = new SQSClient ( {
432
+ region : genericConfig . AwsRegion ,
433
+ } ) ;
434
+ }
435
+ const result = await fastify . sqsClient . send (
436
+ new SendMessageCommand ( {
437
+ QueueUrl : fastify . environmentConfig . SqsQueueUrl ,
438
+ MessageBody : JSON . stringify ( sqsPayload ) ,
439
+ } ) ,
440
+ ) ;
441
+ queueId = result . MessageId || "" ;
424
442
}
425
- const result = await fastify . sqsClient . send (
426
- new SendMessageCommand ( {
427
- QueueUrl : fastify . environmentConfig . SqsQueueUrl ,
428
- MessageBody : JSON . stringify ( sqsPayload ) ,
429
- } ) ,
430
- ) ;
431
- queueId = result . MessageId || "" ;
432
443
}
433
- // If full payment is done, disable the link
434
- if ( paidInFull ) {
435
- request . log . debug ( "Paid in full, disabling link." ) ;
436
- const logStatement = buildAuditLogTransactPut ( {
437
- entry : {
438
- module : Modules . STRIPE ,
439
- actor : eventId ,
440
- target : `Link ${ paymentLinkId } | Invoice ${ unmarshalledEntry . invoiceId } ` ,
441
- message :
442
- "Disabled Stripe payment link as payment was made in full." ,
444
+
445
+ return reply . status ( 200 ) . send ( {
446
+ handled : true ,
447
+ requestId : request . id ,
448
+ queueId : queueId || "" ,
449
+ } ) ;
450
+ }
451
+ return reply
452
+ . code ( 200 )
453
+ . send ( { handled : false , requestId : request . id } ) ;
454
+ case "checkout.session.async_payment_succeeded" :
455
+ case "checkout.session.completed" :
456
+ if ( event . data . object . payment_link ) {
457
+ const eventId = event . id ;
458
+ const paymentAmount = event . data . object . amount_total ;
459
+ const paymentCurrency = event . data . object . currency ;
460
+ const { email, name } = event . data . object . customer_details || {
461
+ email : null ,
462
+ name : null ,
463
+ } ;
464
+ const paymentLinkId = event . data . object . payment_link . toString ( ) ;
465
+ if ( ! paymentLinkId || ! paymentCurrency || ! paymentAmount ) {
466
+ request . log . info ( "Missing required fields." ) ;
467
+ return reply
468
+ . code ( 200 )
469
+ . send ( { handled : false , requestId : request . id } ) ;
470
+ }
471
+ const response = await fastify . dynamoClient . send (
472
+ new QueryCommand ( {
473
+ TableName : genericConfig . StripeLinksDynamoTableName ,
474
+ IndexName : "LinkIdIndex" ,
475
+ KeyConditionExpression : "linkId = :linkId" ,
476
+ ExpressionAttributeValues : {
477
+ ":linkId" : { S : paymentLinkId } ,
443
478
} ,
479
+ } ) ,
480
+ ) ;
481
+ if ( ! response ) {
482
+ throw new DatabaseFetchError ( {
483
+ message : "Could not check for payment link in table." ,
444
484
} ) ;
445
- const dynamoCommand = new TransactWriteItemsCommand ( {
446
- TransactItems : [
447
- ...( logStatement ? [ logStatement ] : [ ] ) ,
485
+ }
486
+ if ( ! response . Items || response . Items ?. length !== 1 ) {
487
+ return reply . status ( 200 ) . send ( {
488
+ handled : false ,
489
+ requestId : request . id ,
490
+ } ) ;
491
+ }
492
+ const unmarshalledEntry = unmarshall ( response . Items [ 0 ] ) as {
493
+ userId : string ;
494
+ invoiceId : string ;
495
+ amount ?: number ;
496
+ priceId ?: string ;
497
+ productId ?: string ;
498
+ } ;
499
+ if ( ! unmarshalledEntry . userId || ! unmarshalledEntry . invoiceId ) {
500
+ return reply . status ( 200 ) . send ( {
501
+ handled : false ,
502
+ requestId : request . id ,
503
+ } ) ;
504
+ }
505
+ const paidInFull = paymentAmount === unmarshalledEntry . amount ;
506
+ const withCurrency = new Intl . NumberFormat ( "en-US" , {
507
+ style : "currency" ,
508
+ currency : paymentCurrency . toUpperCase ( ) ,
509
+ } )
510
+ . formatToParts ( paymentAmount / 100 )
511
+ . map ( ( val ) => val . value )
512
+ . join ( "" ) ;
513
+
514
+ // Notify link owner of payment
515
+ let queueId ;
516
+ if ( event . data . object . payment_status === "unpaid" ) {
517
+ request . log . info (
518
+ `Pending payment of ${ withCurrency } by ${ name } (${ email } ) for payment link ${ paymentLinkId } invoice ID ${ unmarshalledEntry . invoiceId } ). Invoice was tentatively paid ${ paidInFull ? "in full." : "partially." } ` ,
519
+ ) ;
520
+ if ( unmarshalledEntry . userId . includes ( "@" ) ) {
521
+ request . log . info (
522
+ `Sending email to ${ unmarshalledEntry . userId } ...` ,
523
+ ) ;
524
+ const sqsPayload : SQSPayload < AvailableSQSFunctions . EmailNotifications > =
448
525
{
449
- Update : {
450
- TableName : genericConfig . StripeLinksDynamoTableName ,
451
- Key : {
452
- userId : { S : unmarshalledEntry . userId } ,
453
- linkId : { S : paymentLinkId } ,
526
+ function : AvailableSQSFunctions . EmailNotifications ,
527
+ metadata : {
528
+ initiator : eventId ,
529
+ reqId : request . id ,
530
+ } ,
531
+ payload : {
532
+ to : [ unmarshalledEntry . userId ] ,
533
+ subject : `Payment Pending for Invoice ${ unmarshalledEntry . invoiceId } ` ,
534
+ content : `
535
+ ACM @ UIUC has received intent of ${ paidInFull ? "full" : "partial" } payment for Invoice ${ unmarshalledEntry . invoiceId } (${ withCurrency } paid by ${ name } , ${ email } ).
536
+
537
+ The payee has used a payment method which does not settle funds immediately. Therefore, ACM @ UIUC is still waiting for funds to settle and <b>no services should be performed until the funds settle.</b>
538
+
539
+ Please contact Officer Board with any questions.
540
+ ` ,
541
+ callToActionButton : {
542
+ name : "View Your Stripe Links" ,
543
+ url : `${ fastify . environmentConfig . UserFacingUrl } /stripe` ,
454
544
} ,
455
- UpdateExpression : "SET active = :new_val" ,
456
- ConditionExpression : "active = :old_val" ,
457
- ExpressionAttributeValues : {
458
- ":new_val" : { BOOL : false } ,
459
- ":old_val" : { BOOL : true } ,
545
+ } ,
546
+ } ;
547
+ if ( ! fastify . sqsClient ) {
548
+ fastify . sqsClient = new SQSClient ( {
549
+ region : genericConfig . AwsRegion ,
550
+ } ) ;
551
+ }
552
+ const result = await fastify . sqsClient . send (
553
+ new SendMessageCommand ( {
554
+ QueueUrl : fastify . environmentConfig . SqsQueueUrl ,
555
+ MessageBody : JSON . stringify ( sqsPayload ) ,
556
+ } ) ,
557
+ ) ;
558
+ queueId = result . MessageId || "" ;
559
+ }
560
+ } else {
561
+ request . log . info (
562
+ `Registered payment of ${ withCurrency } by ${ name } (${ email } ) for payment link ${ paymentLinkId } invoice ID ${ unmarshalledEntry . invoiceId } ). Invoice was paid ${ paidInFull ? "in full." : "partially." } ` ,
563
+ ) ;
564
+ if ( unmarshalledEntry . userId . includes ( "@" ) ) {
565
+ request . log . info (
566
+ `Sending email to ${ unmarshalledEntry . userId } ...` ,
567
+ ) ;
568
+ const sqsPayload : SQSPayload < AvailableSQSFunctions . EmailNotifications > =
569
+ {
570
+ function : AvailableSQSFunctions . EmailNotifications ,
571
+ metadata : {
572
+ initiator : eventId ,
573
+ reqId : request . id ,
574
+ } ,
575
+ payload : {
576
+ to : [ unmarshalledEntry . userId ] ,
577
+ cc : [
578
+ notificationRecipients [ fastify . runEnvironment ]
579
+ . Treasurer ,
580
+ ] ,
581
+ subject : `Payment Recieved for Invoice ${ unmarshalledEntry . invoiceId } ` ,
582
+ content : `
583
+ ACM @ UIUC has received ${ paidInFull ? "full" : "partial" } payment for Invoice ${ unmarshalledEntry . invoiceId } (${ withCurrency } paid by ${ name } , ${ email } ).
584
+ ${ paidInFull ? "\nThis invoice should now be considered settled.\n" : "" }
585
+ Please contact Officer Board with any questions.` ,
586
+ callToActionButton : {
587
+ name : "View Your Stripe Links" ,
588
+ url : `${ fastify . environmentConfig . UserFacingUrl } /stripe` ,
460
589
} ,
461
590
} ,
462
- } ,
463
- ] ,
464
- } ) ;
465
- if ( unmarshalledEntry . productId ) {
466
- request . log . debug (
467
- `Deactivating Stripe product ${ unmarshalledEntry . productId } ` ,
591
+ } ;
592
+ if ( ! fastify . sqsClient ) {
593
+ fastify . sqsClient = new SQSClient ( {
594
+ region : genericConfig . AwsRegion ,
595
+ } ) ;
596
+ }
597
+ const result = await fastify . sqsClient . send (
598
+ new SendMessageCommand ( {
599
+ QueueUrl : fastify . environmentConfig . SqsQueueUrl ,
600
+ MessageBody : JSON . stringify ( sqsPayload ) ,
601
+ } ) ,
468
602
) ;
469
- await deactivateStripeProduct ( {
603
+ queueId = result . MessageId || "" ;
604
+ }
605
+ // If full payment is done, disable the link
606
+ if ( paidInFull ) {
607
+ request . log . debug ( "Paid in full, disabling link." ) ;
608
+ const logStatement = buildAuditLogTransactPut ( {
609
+ entry : {
610
+ module : Modules . STRIPE ,
611
+ actor : eventId ,
612
+ target : `Link ${ paymentLinkId } | Invoice ${ unmarshalledEntry . invoiceId } ` ,
613
+ message :
614
+ "Disabled Stripe payment link as payment was made in full." ,
615
+ } ,
616
+ } ) ;
617
+ const dynamoCommand = new TransactWriteItemsCommand ( {
618
+ TransactItems : [
619
+ ...( logStatement ? [ logStatement ] : [ ] ) ,
620
+ {
621
+ Update : {
622
+ TableName : genericConfig . StripeLinksDynamoTableName ,
623
+ Key : {
624
+ userId : { S : unmarshalledEntry . userId } ,
625
+ linkId : { S : paymentLinkId } ,
626
+ } ,
627
+ UpdateExpression : "SET active = :new_val" ,
628
+ ConditionExpression : "active = :old_val" ,
629
+ ExpressionAttributeValues : {
630
+ ":new_val" : { BOOL : false } ,
631
+ ":old_val" : { BOOL : true } ,
632
+ } ,
633
+ } ,
634
+ } ,
635
+ ] ,
636
+ } ) ;
637
+ if ( unmarshalledEntry . productId ) {
638
+ request . log . debug (
639
+ `Deactivating Stripe product ${ unmarshalledEntry . productId } ` ,
640
+ ) ;
641
+ await deactivateStripeProduct ( {
642
+ stripeApiKey : secretApiConfig . stripe_secret_key as string ,
643
+ productId : unmarshalledEntry . productId ,
644
+ } ) ;
645
+ }
646
+ request . log . debug ( `Deactivating Stripe link ${ paymentLinkId } ` ) ;
647
+ await deactivateStripeLink ( {
470
648
stripeApiKey : secretApiConfig . stripe_secret_key as string ,
471
- productId : unmarshalledEntry . productId ,
649
+ linkId : paymentLinkId ,
472
650
} ) ;
651
+ await fastify . dynamoClient . send ( dynamoCommand ) ;
473
652
}
474
- request . log . debug ( `Deactivating Stripe link ${ paymentLinkId } ` ) ;
475
- await deactivateStripeLink ( {
476
- stripeApiKey : secretApiConfig . stripe_secret_key as string ,
477
- linkId : paymentLinkId ,
478
- } ) ;
479
- await fastify . dynamoClient . send ( dynamoCommand ) ;
480
653
}
654
+
481
655
return reply . status ( 200 ) . send ( {
482
656
handled : true ,
483
657
requestId : request . id ,
0 commit comments