diff --git a/src/api/functions/stripe.ts b/src/api/functions/stripe.ts index 25005085..3fc85e36 100644 --- a/src/api/functions/stripe.ts +++ b/src/api/functions/stripe.ts @@ -2,6 +2,16 @@ import { isProd } from "api/utils.js"; import { InternalServerError, ValidationError } from "common/errors/index.js"; import { capitalizeFirstLetter } from "common/types/roomRequest.js"; import Stripe from "stripe"; +import { createLock, IoredisAdapter, type SimpleLock } from "redlock-universal"; +import { Redis } from "api/types.js"; +import { + TransactWriteItemsCommand, + QueryCommand, + UpdateItemCommand, + DynamoDBClient, +} from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "common/config.js"; +import { marshall } from "@aws-sdk/util-dynamodb"; export type StripeLinkCreateParams = { invoiceId: string; @@ -325,3 +335,228 @@ export const createStripeCustomer = async ({ ); return customer.id; }; + +export type checkCustomerParams = { + acmOrg: string; + emailDomain: string; + redisClient: Redis; + dynamoClient: DynamoDBClient; + customerEmail: string; + customerName: string; + stripeApiKey: string; +}; + +export type CheckOrCreateResult = { + customerId: string; + needsConfirmation?: boolean; + current?: { name?: string | null; email?: string | null }; + incoming?: { name: string; email: string }; +}; + +export const checkOrCreateCustomer = async ({ + acmOrg, + emailDomain, + redisClient, + dynamoClient, + customerEmail, + customerName, + stripeApiKey, +}: checkCustomerParams): Promise => { + const lock = createLock({ + adapter: new IoredisAdapter(redisClient), + key: `stripe:${acmOrg}:${emailDomain}`, + retryAttempts: 5, + retryDelay: 300, + }) as SimpleLock; + + const pk = `${acmOrg}#${emailDomain}`; + const normalizedEmail = customerEmail.trim().toLowerCase(); + + return await lock.using(async () => { + const checkCustomer = new QueryCommand({ + TableName: genericConfig.StripePaymentsDynamoTableName, + KeyConditionExpression: "primaryKey = :pk AND sortKey = :sk", + ExpressionAttributeValues: { + ":pk": { S: pk }, + ":sk": { S: "CUSTOMER" }, + }, + ConsistentRead: true, + }); + + const customerResponse = await dynamoClient.send(checkCustomer); + + if (customerResponse.Count === 0) { + const customer = await createStripeCustomer({ + email: normalizedEmail, + name: customerName, + stripeApiKey, + }); + + const createCustomer = new TransactWriteItemsCommand({ + TransactItems: [ + { + Put: { + TableName: genericConfig.StripePaymentsDynamoTableName, + Item: marshall( + { + primaryKey: pk, + sortKey: "CUSTOMER", + stripeCustomerId: customer, + totalAmount: 0, + createdAt: new Date().toISOString(), + }, + { removeUndefinedValues: true }, + ), + ConditionExpression: + "attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)", + }, + }, + { + Put: { + TableName: genericConfig.StripePaymentsDynamoTableName, + Item: marshall( + { + primaryKey: pk, + sortKey: `EMAIL#${normalizedEmail}`, + stripeCustomerId: customer, + createdAt: new Date().toISOString(), + }, + { removeUndefinedValues: true }, + ), + ConditionExpression: + "attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)", + }, + }, + ], + }); + await dynamoClient.send(createCustomer); + return { customerId: customer }; + } + + const existingCustomerId = (customerResponse.Items![0] as any) + .stripeCustomerId.S as string; + + const stripeClient = new Stripe(stripeApiKey); + const stripeCustomer = + await stripeClient.customers.retrieve(existingCustomerId); + + const liveName = + "name" in stripeCustomer ? (stripeCustomer as any).name : null; + const liveEmail = + "email" in stripeCustomer ? (stripeCustomer as any).email : null; + + const needsConfirmation = + (!!liveName && liveName !== customerName) || + (!!liveEmail && liveEmail.toLowerCase() !== normalizedEmail); + + const ensureEmailMap = new TransactWriteItemsCommand({ + TransactItems: [ + { + Put: { + TableName: genericConfig.StripePaymentsDynamoTableName, + Item: marshall( + { + primaryKey: pk, + sortKey: `EMAIL#${normalizedEmail}`, + stripeCustomerId: existingCustomerId, + createdAt: new Date().toISOString(), + }, + { removeUndefinedValues: true }, + ), + ConditionExpression: + "attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)", + }, + }, + ], + }); + try { + await dynamoClient.send(ensureEmailMap); + } catch (e) { + // ignore + } + + if (needsConfirmation) { + return { + customerId: existingCustomerId, + needsConfirmation: true, + current: { name: liveName ?? null, email: liveEmail ?? null }, + incoming: { name: customerName, email: normalizedEmail }, + }; + } + + return { customerId: existingCustomerId }; + }); +}; + +export type InvoiceAddParams = { + acmOrg: string; + emailDomain: string; + invoiceId: string; + invoiceAmountUsd: number; + redisClient: Redis; + dynamoClient: DynamoDBClient; + contactEmail: string; + contactName: string; + stripeApiKey: string; +}; + +export const addInvoice = async ({ + contactName, + contactEmail, + acmOrg, + invoiceId, + invoiceAmountUsd, + emailDomain, + redisClient, + dynamoClient, + stripeApiKey, +}: InvoiceAddParams): Promise => { + const pk = `${acmOrg}#${emailDomain}`; + + const result = await checkOrCreateCustomer({ + acmOrg, + emailDomain, + redisClient, + dynamoClient, + customerEmail: contactEmail, + customerName: contactName, + stripeApiKey, + }); + + if (result.needsConfirmation) { + return result; + } + + const dynamoCommand = new TransactWriteItemsCommand({ + TransactItems: [ + { + Put: { + TableName: genericConfig.StripePaymentsDynamoTableName, + Item: marshall( + { + primaryKey: pk, + sortKey: `CHARGE#${invoiceId}`, + invoiceAmtUsd: invoiceAmountUsd, + createdAt: new Date().toISOString(), + }, + { removeUndefinedValues: true }, + ), + }, + Update: { + TableName: genericConfig.StripePaymentsDynamoTableName, + Key: { + primaryKey: { S: pk }, + sortKey: { S: "CUSTOMER" }, + }, + UpdateExpression: "SET totalAmount = totalAmount + :inc", + ExpressionAttributeValues: { + ":inc": { N: invoiceAmountUsd.toString() }, + }, + }, + }, + ], + }); + + await dynamoClient.send(dynamoCommand); + return { customerId: result.customerId }; +}; diff --git a/src/api/routes/stripe.ts b/src/api/routes/stripe.ts index f69676b3..bf78ff69 100644 --- a/src/api/routes/stripe.ts +++ b/src/api/routes/stripe.ts @@ -9,13 +9,16 @@ import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { withRoles, withTags } from "api/components/index.js"; import { buildAuditLogTransactPut } from "api/functions/auditLog.js"; import { + addInvoice, createStripeLink, + createCheckoutSessionWithCustomer, deactivateStripeLink, deactivateStripeProduct, getPaymentMethodDescriptionString, getPaymentMethodForPaymentIntent, paymentMethodTypeToFriendlyName, StripeLinkCreateParams, + InvoiceAddParams, SupportedStripePaymentMethod, supportedStripePaymentMethods, } from "api/functions/stripe.js"; @@ -36,6 +39,7 @@ import { AppRoles } from "common/roles.js"; import { invoiceLinkPostResponseSchema, invoiceLinkPostRequestSchema, + createInvoicePostRequestSchema, invoiceLinkGetResponseSchema, } from "common/types/stripe.js"; import { FastifyPluginAsync } from "fastify"; @@ -110,6 +114,68 @@ const stripeRoutes: FastifyPluginAsync = async (fastify, _options) => { reply.status(200).send(parsed); }, ); + fastify.withTypeProvider().post( + "/createInvoice", + { + schema: withRoles( + [AppRoles.STRIPE_LINK_CREATOR], + withTags(["Stripe"], { + summary: "Create a new invoice.", + body: createInvoicePostRequestSchema, + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + const emailDomain = request.body.contactEmail.split("@").at(-1); + + const secretApiConfig = fastify.secretConfig; + const payload: InvoiceAddParams = { + ...request.body, + emailDomain: emailDomain!, + redisClient: fastify.redisClient, + dynamoClient: fastify.dynamoClient, + stripeApiKey: secretApiConfig.stripe_secret_key as string, + }; + + const result = await addInvoice(payload); + + if (result.needsConfirmation) { + return reply.status(409).send({ + needsConfirmation: true, + customerId: result.customerId, + current: result.current, + incoming: result.incoming, + message: "Customer info differs. Confirm update before proceeding.", + }); + } + + const checkoutUrl = await createCheckoutSessionWithCustomer({ + customerId: result.customerId, + stripeApiKey: secretApiConfig.stripe_secret_key as string, + items: [ + { + price: "", + quantity: 1, + }, + ], + initiator: request.username || "system", + allowPromotionCodes: true, + successUrl: `${fastify.environmentConfig.UserFacingUrl}/success`, + returnUrl: `${fastify.environmentConfig.UserFacingUrl}/cancel`, + metadata: { + acm_org: request.body.acmOrg, + billing_email: request.body.contactEmail, + invoice_id: request.body.invoiceId, + }, + }); + + reply.status(201).send({ + id: request.body.invoiceId, + link: checkoutUrl, + }); + }, + ); fastify.withTypeProvider().post( "/paymentLinks", { diff --git a/src/common/types/stripe.ts b/src/common/types/stripe.ts index 7439ec55..f1b4e3e0 100644 --- a/src/common/types/stripe.ts +++ b/src/common/types/stripe.ts @@ -19,6 +19,48 @@ export type PostInvoiceLinkRequest = z.infer< export type PostInvoiceLinkResponse = z.infer< typeof invoiceLinkPostResponseSchema>; +export const createInvoicePostResponseSchema = z.object({ + id: z.string().min(1), + link: z.url() +}); + +export const createInvoiceConflictResponseSchema = z.object({ + needsConfirmation: z.literal(true), + customerId: z.string().min(1), + current: z.object({ + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + }), + incoming: z.object({ + name: z.string().min(1), + email: z.string().email(), + }), + message: z.string().min(1), +}); + +export const createInvoicePostResponseSchemaUnion = z.union([ + createInvoicePostResponseSchema, // success: 201 + createInvoiceConflictResponseSchema, // info mismatch: 409 +]); + +export type PostCreateInvoiceResponseUnion = z.infer< + typeof createInvoicePostResponseSchemaUnion +>; + +export const createInvoicePostRequestSchema = z.object({ + invoiceId: z.string().min(1), + invoiceAmountUsd: z.number().min(50), + contactName: z.string().min(1), + contactEmail: z.email(), + acmOrg: z.string().min(1) +}); + +export type PostCreateInvoiceRequest = z.infer< + typeof createInvoicePostRequestSchema>; + + +export type PostCreateInvoiceResponse = z.infer< + typeof createInvoicePostResponseSchema>; export const invoiceLinkGetResponseSchema = z.array( z.object({ @@ -33,4 +75,4 @@ export const invoiceLinkGetResponseSchema = z.array( ); export type GetInvoiceLinksResponse = z.infer< - typeof invoiceLinkGetResponseSchema>; \ No newline at end of file + typeof invoiceLinkGetResponseSchema>;