Skip to content

Commit a0c282a

Browse files
authored
support ACH direct debit in invoice settlement (#197)
1 parent 613f2f7 commit a0c282a

File tree

3 files changed

+250
-72
lines changed

3 files changed

+250
-72
lines changed

src/api/functions/stripe.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const createStripeLink = async ({
5757
quantity: 1,
5858
},
5959
],
60+
payment_method_types: ["card", "us_bank_account"],
6061
});
6162
return {
6263
url: paymentLink.url,

src/api/routes/stripe.ts

Lines changed: 246 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import {
1717
StripeLinkCreateParams,
1818
} from "api/functions/stripe.js";
1919
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";
2125
import {
2226
BaseError,
2327
DatabaseFetchError,
@@ -333,7 +337,7 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
333337
});
334338
}
335339
switch (event.type) {
336-
case "checkout.session.completed":
340+
case "checkout.session.async_payment_failed":
337341
if (event.data.object.payment_link) {
338342
const eventId = event.id;
339343
const paymentAmount = event.data.object.amount_total;
@@ -391,93 +395,263 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => {
391395
.formatToParts(paymentAmount / 100)
392396
.map((val) => val.value)
393397
.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
398400
let queueId;
399-
if (unmarshalledEntry.userId.includes("@")) {
401+
if (event.data.object.payment_status === "unpaid") {
400402
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}).`,
402404
);
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,
417415
},
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 || "";
424442
}
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 || "";
432443
}
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 },
443478
},
479+
}),
480+
);
481+
if (!response) {
482+
throw new DatabaseFetchError({
483+
message: "Could not check for payment link in table.",
444484
});
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> =
448525
{
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`,
454544
},
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`,
460589
},
461590
},
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+
}),
468602
);
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({
470648
stripeApiKey: secretApiConfig.stripe_secret_key as string,
471-
productId: unmarshalledEntry.productId,
649+
linkId: paymentLinkId,
472650
});
651+
await fastify.dynamoClient.send(dynamoCommand);
473652
}
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);
480653
}
654+
481655
return reply.status(200).send({
482656
handled: true,
483657
requestId: request.id,

src/common/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,17 +176,20 @@ type NotificationRecipientsType = {
176176
[env in RunEnvironment]: {
177177
OfficerBoard: string;
178178
InfraChairs: string;
179+
Treasurer: string;
179180
};
180181
};
181182

182183
const notificationRecipients: NotificationRecipientsType = {
183184
dev: {
184185
OfficerBoard: 'infra@acm.illinois.edu',
185186
InfraChairs: 'infra@acm.illinois.edu',
187+
Treasurer: 'infra@acm.illinois.edu'
186188
},
187189
prod: {
188190
OfficerBoard: 'officers@acm.illinois.edu',
189191
InfraChairs: 'infra@acm.illinois.edu',
192+
Treasurer: 'treasurer@acm.illinois.edu'
190193
}
191194
}
192195

0 commit comments

Comments
 (0)