From 0db474d2ac1ad33aa8839b3bbadb81011976c461 Mon Sep 17 00:00:00 2001 From: dtfiedler Date: Mon, 18 Dec 2023 14:31:48 -0700 Subject: [PATCH] feat(release): update f3c1916 --- docs/openapi.yaml | 65 +++ package.json | 4 + src/architecture.ts | 2 + src/constants.ts | 8 + src/database/database.ts | 8 +- src/database/dbConstants.ts | 14 + src/database/dbMaps.ts | 22 +- src/database/dbTypes.ts | 53 +- src/database/errors.ts | 14 + src/database/migrator.ts | 77 +++ src/database/postgres.ts | 305 ++++++++-- src/emailProvider.ts | 100 ++++ src/metricRegistry.ts | 6 + src/middleware/architecture.ts | 1 + .../20231129173451_gift_by_email.ts | 27 + src/router.ts | 3 + src/routes/redeem.ts | 107 ++++ .../paymentSuccessEventHandler.ts | 12 +- src/routes/stripe/stripeRoute.ts | 4 +- src/routes/topUp.ts | 88 ++- src/server.ts | 19 +- src/triggerEmail.ts | 57 ++ src/utils/loadSecretsToEnv.ts | 4 + src/utils/validators.ts | 73 +++ tests/dbTestHelper.ts | 8 +- tests/helpers/testHelpers.ts | 3 + tests/router.int.test.ts | 532 +++++++++++++++--- tests/testSetup.ts | 2 + yarn.lock | 34 ++ 29 files changed, 1508 insertions(+), 144 deletions(-) create mode 100644 src/emailProvider.ts create mode 100644 src/migrations/20231129173451_gift_by_email.ts create mode 100644 src/routes/redeem.ts create mode 100644 src/triggerEmail.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e04cdd5..041c35e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -544,3 +544,68 @@ paths: schema: type: string default: "Invalid currency" + + /redeem: + get: + summary: Redeem credits gifted via email + description: Redeem credits gifted via email by providing the destination wallet address for the credits, the redemption ID, and recipient email address + + parameters: + - name: destinationAddress + in: query + required: true + schema: + type: string + description: Destination wallet address + + - name: id + in: query + required: true + schema: + type: string + description: ID for the redemption + + - name: email + in: query + required: true + schema: + type: string + description: Recipient email address for the redemption + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Payment receipt redeemed for 1000 winc! + userBalance: + type: string + example: 1000 + userAddress: + type: string + example: abcdefghijklmnopqrxtuvwxyz123456789ABCDEFGH + userCreationDate: + type: string + example: 2023-05-17T21:46:38.404Z + + "400": + description: Bad Request + content: + text/plain: + schema: + type: string + description: "Error message string dependent on cause" + example: "Failure to redeem payment receipt!" + + "503": + description: Service Unavailable + content: + text/plain: + schema: + type: string + default: "Error while redeeming payment receipt. Unable to reach Database!" diff --git a/package.json b/package.json index f0efe1e..d013fa1 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,12 @@ "@types/koa": "^2.13.4", "@types/koa-router": "^7.4.4", "@types/koa__cors": "^3.3.0", + "@types/mandrill-api": "^1.0.33", "@types/mocha": "^9.1.1", "@types/node": "^18.16.1", "@types/sinon": "^10.0.11", "@types/sinon-chai": "^3.2.9", + "@types/validator": "^13.11.7", "@typescript-eslint/eslint-plugin": "^5.25.0", "@typescript-eslint/parser": "^5.25.0", "axios-mock-adapter": "^1.21.2", @@ -78,12 +80,14 @@ "koa-jwt": "^4.0.4", "koa-router": "11.0.1", "koa2-swagger-ui": "^5.8.0", + "mandrill-api": "^1.0.45", "p-limit": "^3.1.0", "pg": "^8.8.0", "prom-client": "^14.1.0", "raw-body": "^2.5.2", "sinon-chai": "^3.7.0", "stripe": "^11.13.0", + "validator": "^13.11.0", "winston": "^3.8.2", "yaml": "^2.2.2" } diff --git a/src/architecture.ts b/src/architecture.ts index edbeb45..388febe 100644 --- a/src/architecture.ts +++ b/src/architecture.ts @@ -17,10 +17,12 @@ import Stripe from "stripe"; import { Database } from "./database/database"; +import { EmailProvider } from "./emailProvider"; import { PricingService } from "./pricing/pricing"; export interface Architecture { paymentDatabase: Database; pricingService: PricingService; stripe: Stripe; + emailProvider?: EmailProvider; } diff --git a/src/constants.ts b/src/constants.ts index eddd6d2..ab151de 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -314,3 +314,11 @@ export const recognizedCountries = [ export const promoCodeBackfills = { welcomeTwentyPercentOff: "TOKEN2049", }; + +export const maxGiftMessageLength = process.env.MAX_GIFT_MESSAGE_LENGTH ?? 250; + +export const giftingEmailAddress = + process.env.GIFTING_EMAILL_ADDRESS ?? "gift@ardrive.io"; + +/** gifting on top up via email depends on GIFTING_ENABLED="true" env var */ +export const isGiftingEnabled = process.env.GIFTING_ENABLED === "true"; diff --git a/src/database/database.ts b/src/database/database.ts index ac3714b..b47f583 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -30,6 +30,7 @@ import { SingleUseCodePaymentCatalog, TopUpQuote, TopUpQuoteId, + UnredeemedGift, UploadAdjustmentCatalog, User, UserAddress, @@ -47,7 +48,7 @@ export interface Database { getBalance: (userAddress: UserAddress) => Promise; createPaymentReceipt: ( paymentReceipt: CreatePaymentReceiptParams - ) => Promise; + ) => Promise; getPaymentReceipt: ( paymentReceiptId: PaymentReceiptId ) => Promise; @@ -77,4 +78,9 @@ export interface Database { ) => Promise; getUploadAdjustmentCatalogs: () => Promise; getPaymentAdjustmentCatalogs(): Promise; + redeemGift: (params: { + paymentReceiptId: string; + recipientEmail: string; + destinationAddress: string; + }) => Promise; } diff --git a/src/database/dbConstants.ts b/src/database/dbConstants.ts index f37f5d3..e88d8de 100644 --- a/src/database/dbConstants.ts +++ b/src/database/dbConstants.ts @@ -31,6 +31,9 @@ export const tableNames = { paymentAdjustmentCatalog: "payment_adjustment_catalog", singleUseCodePaymentAdjustmentCatalog: "single_use_code_payment_adjustment_catalog", + + unredeemedGift: "unredeemed_gift", + redeemedGift: "redeemed_gift", } as const; export const columnNames = { @@ -52,6 +55,7 @@ export const columnNames = { quoteExpirationDate: "quote_expiration_date", quoteCreationDate: "quote_creation_date", paymentProvider: "payment_provider", + giftMessage: "gift_message", // Optional gift message, ignored in non-gift top-ups for now // Failed top up quote failedReason: "failed_reason", @@ -105,4 +109,14 @@ export const columnNames = { operator: "operator", operatorMagnitude: "operator_magnitude", + + // Unredeemed Gift + giftedWincAmount: "gifted_winc_amount", + recipientEmail: "recipient_email", + senderEmail: "sender_email", + creationDate: "creation_date", + expirationDate: "expiration_date", + + // Redeemed Gift + redemptionDate: "redemption_date", } as const; diff --git a/src/database/dbMaps.ts b/src/database/dbMaps.ts index ebb2394..6c85c81 100644 --- a/src/database/dbMaps.ts +++ b/src/database/dbMaps.ts @@ -20,6 +20,7 @@ import { AdjustmentCatalogDBResult, ChargebackReceipt, ChargebackReceiptDBResult, + DestinationAddressType, FailedTopUpQuote, FailedTopUpQuoteDBResult, PaymentAdjustmentCatalog, @@ -31,9 +32,12 @@ import { SingleUseCodePaymentCatalogDBResult, TopUpQuote, TopUpQuoteDBResult, + UnredeemedGift, + UnredeemedGiftDBResult, UploadAdjustmentCatalog, UploadAdjustmentCatalogDBResult, User, + UserAddressType, UserDBResult, } from "./dbTypes"; @@ -47,7 +51,7 @@ export function userDBMap({ return { promotionalInfo: promotional_info as PromotionalInfo, userAddress: user_address, - userAddressType: user_address_type, + userAddressType: user_address_type as UserAddressType, userCreationDate: user_creation_date, winstonCreditBalance: new Winston(winston_credit_balance), }; @@ -74,7 +78,7 @@ export function topUpQuoteDBMap({ quoteCreationDate: quote_creation_date, quoteExpirationDate: quote_expiration_date, destinationAddress: destination_address, - destinationAddressType: destination_address_type, + destinationAddressType: destination_address_type as DestinationAddressType, winstonCreditAmount: new Winston(winston_credit_amount), }; } @@ -159,3 +163,17 @@ export function singleUseCodePaymentCatalogDBMap( maximumDiscountAmount: dbResult.maximum_discount_amount, }; } + +export function unredeemedGiftDBMap( + dbResult: UnredeemedGiftDBResult +): UnredeemedGift { + return { + paymentReceiptId: dbResult.payment_receipt_id, + giftedWincAmount: new Winston(dbResult.gifted_winc_amount), + recipientEmail: dbResult.recipient_email, + giftMessage: dbResult.gift_message, + giftCreationDate: dbResult.creation_date, + giftExpirationDate: dbResult.expiration_date, + senderEmail: dbResult.sender_email, + }; +} diff --git a/src/database/dbTypes.ts b/src/database/dbTypes.ts index ebf6a82..3be4250 100644 --- a/src/database/dbTypes.ts +++ b/src/database/dbTypes.ts @@ -42,7 +42,12 @@ export interface PaymentAdjustment extends Adjustment { } export type UserAddress = string | PublicArweaveAddress; -export type UserAddressType = string | "arweave"; + +export const userAddressTypes = ["arweave"] as const; +export type UserAddressType = (typeof userAddressTypes)[number]; + +export const destinationAddressTypes = ["email", "arweave"] as const; +export type DestinationAddressType = (typeof destinationAddressTypes)[number]; /** Currently using Postgres Date type (ISO String) */ export type Timestamp = string; @@ -85,7 +90,7 @@ export interface User { export interface TopUpQuote { topUpQuoteId: TopUpQuoteId; destinationAddress: UserAddress; - destinationAddressType: UserAddressType; + destinationAddressType: DestinationAddressType; paymentAmount: PaymentAmount; quotedPaymentAmount: PaymentAmount; currencyType: CurrencyType; @@ -93,6 +98,7 @@ export interface TopUpQuote { quoteExpirationDate: Timestamp; quoteCreationDate: Timestamp; paymentProvider: PaymentProvider; + giftMessage?: string; } export type CreateTopUpQuoteParams = Omit & { @@ -113,6 +119,7 @@ export interface CreatePaymentReceiptParams { topUpQuoteId: TopUpQuoteId; paymentAmount: PaymentAmount; currencyType: CurrencyType; + senderEmail?: string; } export interface ChargebackReceipt extends PaymentReceipt { @@ -180,7 +187,10 @@ export type AuditChangeReason = | "payment" | "account_creation" | "chargeback" - | "refund"; + | "refund" + | "gifted_payment" + | "gifted_payment_redemption" + | "gifted_account_creation"; export interface AuditLogInsert { user_address: string; @@ -209,6 +219,7 @@ export interface TopUpQuoteDBInsert { winston_credit_amount: string; payment_provider: string; quote_expiration_date: string; + gift_message?: string; } export interface TopUpQuoteDBResult extends TopUpQuoteDBInsert { @@ -338,3 +349,39 @@ export interface PaymentAdjustmentDBInsert extends AdjustmentDBInsert { export interface PaymentAdjustmentDBResult extends PaymentAdjustmentDBInsert, AdjustmentDBResult {} + +export interface UnredeemedGiftDBInsert { + payment_receipt_id: string; + gifted_winc_amount: string; + recipient_email: string; + sender_email?: string; + gift_message?: string; +} + +export interface UnredeemedGiftDBResult extends UnredeemedGiftDBInsert { + creation_date: string; + expiration_date: string; +} + +export interface RedeemedGiftDBInsert extends UnredeemedGiftDBResult { + destination_address: string; +} + +export interface RedeemedGiftDBResult extends RedeemedGiftDBInsert { + redemption_date: string; +} + +export interface UnredeemedGift { + paymentReceiptId: PaymentReceiptId; + giftedWincAmount: WC; + recipientEmail: string; + senderEmail?: string; + giftMessage?: string; + giftCreationDate: Timestamp; + giftExpirationDate: Timestamp; +} + +export interface RedeemedGift extends UnredeemedGift { + destinationAddress: UserAddress; + redemptionDate: Timestamp; +} diff --git a/src/database/errors.ts b/src/database/errors.ts index a940941..b8ce2d3 100644 --- a/src/database/errors.ts +++ b/src/database/errors.ts @@ -118,3 +118,17 @@ export class PromoCodeExceedsMaxUses extends PromoCodeError { this.name = "PromoCodeExceedsMaxUses"; } } + +export class GiftRedemptionError extends Error { + constructor(errorMessage = "Failure to redeem payment receipt!") { + super(errorMessage); + this.name = "GiftRedemptionError"; + } +} + +export class GiftAlreadyRedeemed extends GiftRedemptionError { + constructor() { + super("Gift has already been redeemed!"); + this.name = "GiftAlreadyRedeemed"; + } +} diff --git a/src/database/migrator.ts b/src/database/migrator.ts index 371e7f2..881f0bb 100644 --- a/src/database/migrator.ts +++ b/src/database/migrator.ts @@ -154,3 +154,80 @@ export class MaxDiscountMigrator extends Migrator { }); } } + +export class GiftByEmailMigrator extends Migrator { + constructor(private readonly knex: Knex) { + super(); + } + + private topUpQuoteTableNames = [ + tableNames.topUpQuote, + tableNames.paymentReceipt, + tableNames.failedTopUpQuote, + tableNames.chargebackReceipt, + ]; + + public migrate() { + return this.operate({ + name: "migrate to gift by email", + operation: async () => { + await Promise.all( + this.topUpQuoteTableNames.map((table) => + this.knex.schema.alterTable(table, (table) => { + table.string(columnNames.giftMessage).nullable(); + }) + ) + ); + + await this.knex.schema.createTable( + tableNames.unredeemedGift, + (table) => { + table.string(columnNames.paymentReceiptId).primary(); + table.string(columnNames.recipientEmail).notNullable(); + table + .timestamp(columnNames.creationDate) + .notNullable() + .defaultTo(this.knex.fn.now()); + table + .timestamp(columnNames.expirationDate) + .notNullable() + .defaultTo(this.knex.raw("now() + interval '1 year'")); + table.string(columnNames.giftedWincAmount).notNullable(); + table.string(columnNames.giftMessage).nullable(); + table.string(columnNames.senderEmail).nullable(); + } + ); + + await this.knex.schema.createTableLike( + tableNames.redeemedGift, + tableNames.unredeemedGift, + (table) => { + table + .timestamp(columnNames.redemptionDate) + .notNullable() + .defaultTo(this.knex.fn.now()); + table.string(columnNames.destinationAddress).notNullable(); + } + ); + }, + }); + } + + public rollback() { + return this.operate({ + name: "rollback from gift by email", + operation: async () => { + await Promise.all( + this.topUpQuoteTableNames.map((table) => + this.knex.schema.alterTable(table, (table) => { + table.dropColumn(columnNames.giftMessage); + }) + ) + ); + + await this.knex.schema.dropTable(tableNames.unredeemedGift); + await this.knex.schema.dropTable(tableNames.redeemedGift); + }, + }); + } +} diff --git a/src/database/postgres.ts b/src/database/postgres.ts index 6fdf4e7..d20af7b 100644 --- a/src/database/postgres.ts +++ b/src/database/postgres.ts @@ -29,6 +29,7 @@ import { paymentReceiptDBMap, singleUseCodePaymentCatalogDBMap, topUpQuoteDBMap, + unredeemedGiftDBMap, uploadAdjustmentCatalogDBMap, userDBMap, } from "./dbMaps"; @@ -50,18 +51,26 @@ import { PaymentReceipt, PaymentReceiptDBResult, PromotionalInfo, + RedeemedGiftDBInsert, + RedeemedGiftDBResult, SingleUseCodePaymentCatalog, SingleUseCodePaymentCatalogDBResult, TopUpQuote, TopUpQuoteDBInsert, TopUpQuoteDBResult, + UnredeemedGift, + UnredeemedGiftDBInsert, + UnredeemedGiftDBResult, UploadAdjustmentCatalog, UploadAdjustmentCatalogDBResult, UploadAdjustmentDBInsert, User, + UserDBInsert, UserDBResult, } from "./dbTypes"; import { + GiftAlreadyRedeemed, + GiftRedemptionError, InsufficientBalance, PromoCodeExceedsMaxUses, PromoCodeExpired, @@ -119,6 +128,7 @@ export class PostgresDatabase implements Database { quoteExpirationDate, topUpQuoteId, winstonCreditAmount, + giftMessage, } = topUpQuote; const topUpQuoteDbInsert: TopUpQuoteDBInsert = { @@ -131,6 +141,7 @@ export class PostgresDatabase implements Database { quote_expiration_date: quoteExpirationDate, top_up_quote_id: topUpQuoteId, winston_credit_amount: winstonCreditAmount.toString(), + gift_message: giftMessage, }; await this.writer.transaction(async (knexTransaction) => { @@ -215,15 +226,20 @@ export class PostgresDatabase implements Database { public async createPaymentReceipt( paymentReceipt: CreatePaymentReceiptParams - ): Promise { + ): Promise { this.log.info("Inserting new payment receipt...", { paymentReceipt, }); - const { topUpQuoteId, paymentReceiptId, paymentAmount, currencyType } = - paymentReceipt; + const { + topUpQuoteId, + paymentReceiptId, + paymentAmount, + currencyType, + senderEmail, + } = paymentReceipt; - await this.writer.transaction(async (knexTransaction) => { + return this.writer.transaction(async (knexTransaction) => { const topUpQuoteDbResults = await knexTransaction( tableNames.topUpQuote ).where({ @@ -300,59 +316,229 @@ export class PostgresDatabase implements Database { tableNames.paymentReceipt ).insert({ ...topUpQuote[0], payment_receipt_id: paymentReceiptId }); + if (destination_address_type === "email") { + const unredeemedGiftDbInsert: UnredeemedGiftDBInsert = { + recipient_email: destination_address, + payment_receipt_id: paymentReceiptId, + gifted_winc_amount: winston_credit_amount, + gift_message: topUpQuote[0].gift_message, + sender_email: senderEmail, + }; + const unredeemedGiftDbResult = + await knexTransaction( + tableNames.unredeemedGift + ) + .insert(unredeemedGiftDbInsert) + .returning("*"); + + const auditLogInsert: AuditLogInsert = { + user_address: destination_address, + winston_credit_amount: "0", + change_reason: "gifted_payment", + change_id: paymentReceiptId, + }; + await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + + return unredeemedGiftDbResult.map(unredeemedGiftDBMap)[0]; + } else { + const destinationUser = ( + await knexTransaction(tableNames.user).where({ + user_address: destination_address, + }) + )[0]; + + if (destinationUser === undefined) { + this.log.info("No existing user was found; creating new user...", { + userAddress: destination_address, + newBalance: winston_credit_amount, + paymentReceipt, + }); + await knexTransaction(tableNames.user).insert({ + user_address: destination_address, + user_address_type: destination_address_type, + winston_credit_balance: winston_credit_amount, + }); + + const auditLogInsert: AuditLogInsert = { + user_address: destination_address, + winston_credit_amount, + change_reason: "account_creation", + change_id: paymentReceiptId, + }; + await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + } else { + // Increment balance of existing user + const currentBalance = new Winston( + destinationUser.winston_credit_balance + ); + const newBalance = currentBalance.plus( + new Winston(winston_credit_amount) + ); + + this.log.info("Incrementing balance...", { + userAddress: destination_address, + currentBalance, + newBalance, + paymentReceipt, + }); + + await knexTransaction(tableNames.user) + .where({ + user_address: destination_address, + }) + .update({ winston_credit_balance: newBalance.toString() }); + + const auditLogInsert: AuditLogInsert = { + user_address: destination_address, + winston_credit_amount, + change_reason: "payment", + change_id: paymentReceiptId, + }; + await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + } + return; + } + }); + } + + public async redeemGift({ + destinationAddress, + paymentReceiptId, + recipientEmail, + }: { + paymentReceiptId: string; + recipientEmail: string; + destinationAddress: string; + }): Promise { + return this.writer.transaction(async (knexTransaction) => { + const unredeemedGiftDbResults = + await knexTransaction( + tableNames.unredeemedGift + ).where({ + payment_receipt_id: paymentReceiptId, + }); + + if (unredeemedGiftDbResults.length === 0) { + logger.warn( + `No unredeemed gift found in database with payment receipt ID '${paymentReceiptId}'` + ); + + const redeemedDbResults = await knexTransaction( + tableNames.redeemedGift + ).where({ + payment_receipt_id: paymentReceiptId, + }); + if (redeemedDbResults.length > 0) { + logger.warn( + `Payment receipt ID '${paymentReceiptId}' has already been redeemed!` + ); + throw new GiftAlreadyRedeemed(); + } + + throw new GiftRedemptionError(); + } + + const paymentReceiptDbResults = + await knexTransaction( + tableNames.paymentReceipt + ).where({ + payment_receipt_id: paymentReceiptId, + }); + + if (paymentReceiptDbResults.length === 0) { + logger.warn( + `No payment receipt found in database with payment receipt ID '${paymentReceiptId}'` + ); + throw new GiftRedemptionError(); + } + + const unredeemedGiftDbResult = unredeemedGiftDbResults[0]; + + if (unredeemedGiftDbResult.recipient_email !== recipientEmail) { + logger.warn( + `Recipient email '${recipientEmail}' does not match unredeemed gift recipient email '${unredeemedGiftDbResult.recipient_email}'` + ); + throw new GiftRedemptionError(); + } + + const redeemedGiftDbInsert: RedeemedGiftDBInsert = { + ...unredeemedGiftDbResult, + destination_address: destinationAddress, + }; + + await knexTransaction(tableNames.unredeemedGift) + .where({ + payment_receipt_id: paymentReceiptId, + }) + .del(); + + await knexTransaction(tableNames.redeemedGift).insert( + redeemedGiftDbInsert + ); + const destinationUser = ( await knexTransaction(tableNames.user).where({ - user_address: destination_address, + user_address: destinationAddress, }) )[0]; + if (destinationUser === undefined) { this.log.info("No existing user was found; creating new user...", { - userAddress: destination_address, - newBalance: winston_credit_amount, - paymentReceipt, - }); - await knexTransaction(tableNames.user).insert({ - user_address: destination_address, - user_address_type: destination_address_type, - winston_credit_balance: winston_credit_amount, + userAddress: destinationAddress, + newBalance: unredeemedGiftDbResult.gifted_winc_amount, }); + const userDbInsert: UserDBInsert = { + user_address: destinationAddress, + user_address_type: "arweave", + winston_credit_balance: unredeemedGiftDbResult.gifted_winc_amount, + }; + const userDbResult = await knexTransaction( + tableNames.user + ) + .insert(userDbInsert) + .returning("*"); const auditLogInsert: AuditLogInsert = { - user_address: destination_address, - winston_credit_amount, - change_reason: "account_creation", + user_address: destinationAddress, + winston_credit_amount: unredeemedGiftDbResult.gifted_winc_amount, + change_reason: "gifted_account_creation", change_id: paymentReceiptId, }; await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + return userDbResult.map(userDBMap)[0]; } else { // Increment balance of existing user const currentBalance = new Winston( destinationUser.winston_credit_balance ); const newBalance = currentBalance.plus( - new Winston(winston_credit_amount) + new Winston(unredeemedGiftDbResult.gifted_winc_amount) ); this.log.info("Incrementing balance...", { - userAddress: destination_address, + userAddress: destinationAddress, currentBalance, newBalance, - paymentReceipt, }); - await knexTransaction(tableNames.user) + const userDbResult = await knexTransaction( + tableNames.user + ) .where({ - user_address: destination_address, + user_address: destinationAddress, }) - .update({ winston_credit_balance: newBalance.toString() }); + .update({ winston_credit_balance: newBalance.toString() }) + .returning("*"); const auditLogInsert: AuditLogInsert = { - user_address: destination_address, - winston_credit_amount, - change_reason: "payment", + user_address: destinationAddress, + winston_credit_amount: unredeemedGiftDbResult.gifted_winc_amount, + change_reason: "gifted_payment_redemption", change_id: paymentReceiptId, }; await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + + return userDbResult.map(userDBMap)[0]; } }); } @@ -432,33 +618,66 @@ export class PostgresDatabase implements Database { destinationAddress, paymentReceiptId, winstonCreditAmount: winstonClawbackAmount, + destinationAddressType, } = await this.getPaymentReceiptByTopUpQuoteId( topUpQuoteId, knexTransaction ); - const user = await this.getUser(destinationAddress, knexTransaction); + let userAddress: string | undefined; - // Decrement balance of existing user - const currentBalance = user.winstonCreditBalance; + if (destinationAddressType === "email") { + const redeemedGiftDbResults = + await knexTransaction( + tableNames.unredeemedGift + ).where({ + payment_receipt_id: paymentReceiptId, + }); - // this could result in a negative balance for a user, will throw an error if non-integer winston balance - const newBalance = currentBalance.minus(winstonClawbackAmount); + if (redeemedGiftDbResults.length === 0) { + // When no redeemed exists yet, delete the unredeemed gift and leave user address undefined + await knexTransaction( + tableNames.unredeemedGift + ) + .where({ + payment_receipt_id: paymentReceiptId, + }) + .del(); + } else { + userAddress = redeemedGiftDbResults[0].destination_address; + } + } else { + userAddress = destinationAddress; + } - // Update the users balance. - await knexTransaction(tableNames.user) - .where({ - user_address: destinationAddress, - }) - .update({ winston_credit_balance: newBalance.toString() }); + if (userAddress) { + const user = await this.getUser(userAddress, knexTransaction); - const auditLogInsert: AuditLogInsert = { - user_address: destinationAddress, - winston_credit_amount: `-${winstonClawbackAmount.toString()}`, // a negative value because this amount was withdrawn from the users balance - change_reason: "chargeback", - change_id: chargebackReceiptId, - }; - await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + // Decrement balance of existing user + const currentBalance = user.winstonCreditBalance; + + // this could result in a negative balance for a user, will throw an error if non-integer winston balance + const newBalance = currentBalance.minus(winstonClawbackAmount); + + // Update the users balance. + await knexTransaction(tableNames.user) + .where({ + user_address: destinationAddress, + }) + .update({ winston_credit_balance: newBalance.toString() }); + + const auditLogInsert: AuditLogInsert = { + user_address: destinationAddress, + winston_credit_amount: `-${winstonClawbackAmount.toString()}`, // a negative value because this amount was withdrawn from the users balance + change_reason: "chargeback", + change_id: chargebackReceiptId, + }; + await knexTransaction(tableNames.auditLog).insert(auditLogInsert); + } else { + logger.warn( + `Chargeback receipt created for payment receipt ID '${paymentReceiptId}' but user has not redeemed gift yet!` + ); + } // Remove from payment receipt table, const paymentReceiptDbResult = diff --git a/src/emailProvider.ts b/src/emailProvider.ts new file mode 100644 index 0000000..18c3480 --- /dev/null +++ b/src/emailProvider.ts @@ -0,0 +1,100 @@ +/** + * Copyright (C) 2022-2023 Permanent Data Solutions, Inc. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Mandrill } from "mandrill-api"; +import winston from "winston"; + +import globalLogger from "./logger"; + +interface TemplateContent { + credits: string; + giftCode: string; + recipientEmail: string; + senderEmail?: string; + giftMessage?: string; +} + +export interface EmailProvider { + sendEmail({ + credits, + giftCode, + recipientEmail, + giftMessage, + senderEmail, + }: TemplateContent): Promise; +} + +export class MandrillEmailProvider implements EmailProvider { + private readonly logger; + private readonly mandrillClient: Mandrill; + + constructor(readonly apiKey: string, logger: winston.Logger = globalLogger) { + this.mandrillClient = new Mandrill(apiKey); + this.logger = logger.child({ + class: this.constructor.name, + }); + } + + public async sendEmail({ + credits, + giftCode, + recipientEmail, + giftMessage, + senderEmail, + }: TemplateContent): Promise { + const templateName = "gift-credits"; + const templateContent = [ + { + name: "CREDITS", + content: credits, + }, + { + name: "CODE", + content: giftCode, + }, + { + name: "GIFTMESSAGE", + content: giftMessage, + }, + { + name: "SENDEREMAIL", + content: senderEmail, + }, + ]; + + try { + const result = await this.mandrillClient.messages.sendTemplate({ + async: true, + template_name: templateName, + template_content: [], + message: { + to: [ + { + email: recipientEmail, + name: recipientEmail, + type: "to", + }, + ], + global_merge_vars: templateContent, + }, + }); + this.logger.info("Email sent successfully", result); + } catch (error) { + this.logger.error("Failed to send email via Mandrill!", error); + throw error; + } + } +} diff --git a/src/metricRegistry.ts b/src/metricRegistry.ts index 5997866..737c4d6 100644 --- a/src/metricRegistry.ts +++ b/src/metricRegistry.ts @@ -67,6 +67,11 @@ export class MetricRegistry { help: "Count of unauthorized activity on protected routes", }); + public static giftEmailTriggerFailure = new promClient.Counter({ + name: "gift_email_trigger_failure", + help: "Count of gift email trigger failures for unredeemed gifts", + }); + private constructor() { this.registry = new promClient.Registry(); @@ -83,6 +88,7 @@ export class MetricRegistry { this.registry.registerMetric( MetricRegistry.unauthorizedProtectedRouteActivity ); + this.registry.registerMetric(MetricRegistry.giftEmailTriggerFailure); } public static getInstance(): MetricRegistry { diff --git a/src/middleware/architecture.ts b/src/middleware/architecture.ts index 6ac3a64..fd270e9 100644 --- a/src/middleware/architecture.ts +++ b/src/middleware/architecture.ts @@ -27,5 +27,6 @@ export async function architectureMiddleware( ctx.state.paymentDatabase = arch.paymentDatabase; ctx.state.pricingService = arch.pricingService; ctx.state.stripe = arch.stripe; + ctx.state.emailProvider = arch.emailProvider; return next(); } diff --git a/src/migrations/20231129173451_gift_by_email.ts b/src/migrations/20231129173451_gift_by_email.ts new file mode 100644 index 0000000..6159d03 --- /dev/null +++ b/src/migrations/20231129173451_gift_by_email.ts @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2022-2023 Permanent Data Solutions, Inc. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Knex } from "knex"; + +import { GiftByEmailMigrator } from "../database/migrator"; + +export async function up(knex: Knex): Promise { + return new GiftByEmailMigrator(knex).migrate(); +} + +export async function down(knex: Knex): Promise { + return new GiftByEmailMigrator(knex).rollback(); +} diff --git a/src/router.ts b/src/router.ts index f21a41a..3763504 100644 --- a/src/router.ts +++ b/src/router.ts @@ -26,6 +26,7 @@ import { countriesHandler } from "./routes/countries"; import { currenciesRoute } from "./routes/currencies"; import { priceRoutes } from "./routes/priceRoutes"; import { fiatToArRateHandler, ratesHandler } from "./routes/rates"; +import { redeem } from "./routes/redeem"; import { refundBalance } from "./routes/refundBalance"; import { reserveBalance } from "./routes/reserveBalance"; import { stripeRoute } from "./routes/stripe/stripeRoute"; @@ -53,6 +54,8 @@ router.get( topUp ); +router.get("/v1/redeem", redeem); + // TODO: Add API for admin routes that create and manage promotions router.post("/v1/stripe-webhook", stripeRoute); diff --git a/src/routes/redeem.ts b/src/routes/redeem.ts new file mode 100644 index 0000000..3ba3ced --- /dev/null +++ b/src/routes/redeem.ts @@ -0,0 +1,107 @@ +/** + * Copyright (C) 2022-2023 Permanent Data Solutions, Inc. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { Next } from "koa"; +import validator from "validator"; + +import { User } from "../database/dbTypes"; +import { GiftRedemptionError } from "../database/errors"; +import { KoaContext } from "../server"; +import { isValidArweaveBase64URL } from "../utils/base64"; +import { validateSingularQueryParameter } from "../utils/validators"; + +export async function redeem(ctx: KoaContext, next: Next): Promise { + const { paymentDatabase, logger: _logger } = ctx.state; + let logger = _logger; + + const { + email: rawEmail, + destinationAddress: rawDestinationAddress, + id: rawPaymentReceiptId, + } = ctx.query; + + const email = validateSingularQueryParameter(ctx, rawEmail); + const destinationAddress = validateSingularQueryParameter( + ctx, + rawDestinationAddress + ); + const paymentReceiptId = validateSingularQueryParameter( + ctx, + rawPaymentReceiptId + ); + if (!email || !destinationAddress || !paymentReceiptId) { + return next(); + } + + if (!isValidArweaveBase64URL(destinationAddress)) { + ctx.response.status = 400; + ctx.body = + "Provided destination address is not a valid Arweave native address!"; + logger.info("top-up GET -- Invalid destination address", ctx.params); + return next(); + } + + if (!validator.isEmail(email)) { + ctx.response.status = 400; + ctx.body = "Provided recipient email address is not a valid email!"; + logger.info("top-up GET -- Invalid destination address", ctx.params); + return next(); + } + const recipientEmail = validator.escape(email); + + logger = logger.child({ + destinationAddress, + paymentReceiptId, + }); + + let user: User; + try { + logger.info("Redeeming payment receipt"); + user = await paymentDatabase.redeemGift({ + destinationAddress, + paymentReceiptId, + recipientEmail, + }); + } catch (error) { + if (error instanceof GiftRedemptionError) { + ctx.response.status = 400; + ctx.body = error.message; + logger.info(error.message); + return next(); + } + const message = + "Error while redeeming payment receipt. Unable to reach Database!"; + logger.error("Error redeeming payment receipt"); + ctx.response.status = 503; + ctx.body = message; + return next(); + } + + const message = `Payment receipt redeemed for ${user.winstonCreditBalance} winc!`; + + logger = logger.child({ user }); + logger.info(message); + + ctx.response.status = 200; + ctx.body = { + message, + userBalance: user.winstonCreditBalance, + userAddress: user.userAddress, + userCreationDate: user.userCreationDate, + }; + + return next(); +} diff --git a/src/routes/stripe/eventHandlers/paymentSuccessEventHandler.ts b/src/routes/stripe/eventHandlers/paymentSuccessEventHandler.ts index 27ab5a5..e966b10 100644 --- a/src/routes/stripe/eventHandlers/paymentSuccessEventHandler.ts +++ b/src/routes/stripe/eventHandlers/paymentSuccessEventHandler.ts @@ -18,13 +18,16 @@ import { randomUUID } from "crypto"; import { Stripe } from "stripe"; import { Database } from "../../../database/database"; +import { EmailProvider } from "../../../emailProvider"; import logger from "../../../logger"; import { MetricRegistry } from "../../../metricRegistry"; +import { triggerEmail } from "../../../triggerEmail"; export async function handlePaymentSuccessEvent( pi: Stripe.PaymentIntent, paymentDatabase: Database, - stripe: Stripe + stripe: Stripe, + emailProvider?: EmailProvider ) { logger.info("💰 Payment Success Event Triggered", pi.metadata); @@ -44,17 +47,22 @@ export async function handlePaymentSuccessEvent( logger.info("Creating payment receipt...", loggerObject); - await paymentDatabase.createPaymentReceipt({ + const maybeUnredeemedGift = await paymentDatabase.createPaymentReceipt({ paymentReceiptId, paymentAmount: pi.amount, currencyType: pi.currency, topUpQuoteId, + senderEmail: pi.receipt_email ?? undefined, }); logger.info(`💸 Payment Receipt created!`, loggerObject); MetricRegistry.paymentSuccessCounter.inc(); MetricRegistry.topUpsCounter.inc(Number(winstonCreditAmount)); + + if (maybeUnredeemedGift) { + await triggerEmail(maybeUnredeemedGift, emailProvider); + } } catch (error) { logger.error("❌ Payment receipt creation has failed!", loggerObject); logger.error(error); diff --git a/src/routes/stripe/stripeRoute.ts b/src/routes/stripe/stripeRoute.ts index 884d933..63ccb4b 100644 --- a/src/routes/stripe/stripeRoute.ts +++ b/src/routes/stripe/stripeRoute.ts @@ -31,6 +31,7 @@ export async function stripeRoute(ctx: KoaContext, next: Next) { } const stripe = ctx.state.stripe; + const emailProvider = ctx.state.emailProvider; // get the webhook signature and raw body for verification const sig = ctx.request.headers["stripe-signature"] as string; @@ -76,7 +77,8 @@ export async function stripeRoute(ctx: KoaContext, next: Next) { handlePaymentSuccessEvent( data.object as Stripe.PaymentIntent, ctx.state.paymentDatabase, - stripe + stripe, + emailProvider ); } catch (error) { logger.error( diff --git a/src/routes/topUp.ts b/src/routes/topUp.ts index 9893ff2..71285d7 100644 --- a/src/routes/topUp.ts +++ b/src/routes/topUp.ts @@ -17,10 +17,12 @@ import { randomUUID } from "crypto"; import { Next } from "koa"; import Stripe from "stripe"; +import validator from "validator"; import { CurrencyLimitations, electronicallySuppliedServicesTaxCode, + isGiftingEnabled, paymentIntentTopUpMethod, topUpMethods, } from "../constants"; @@ -33,14 +35,23 @@ import { Payment } from "../types/payment"; import { winstonToArc } from "../types/winston"; import { isValidArweaveBase64URL } from "../utils/base64"; import { parseQueryParams } from "../utils/parseQueryParams"; +import { + validateDestinationAddressType, + validateGiftMessage, +} from "../utils/validators"; export async function topUp(ctx: KoaContext, next: Next) { const logger = ctx.state.logger; const { pricingService, paymentDatabase, stripe } = ctx.state; - const { amount, currency, method, address: destinationAddress } = ctx.params; + const { + amount, + currency, + method, + address: rawDestinationAddress, + } = ctx.params; - const loggerObject = { amount, currency, method, destinationAddress }; + const loggerObject = { amount, currency, method, rawDestinationAddress }; if (!topUpMethods.includes(method)) { ctx.response.status = 400; @@ -49,13 +60,54 @@ export async function topUp(ctx: KoaContext, next: Next) { return next(); } - if (!isValidArweaveBase64URL(destinationAddress)) { - ctx.response.status = 403; - ctx.body = "Destination address is not a valid Arweave native address!"; - logger.info("top-up GET -- Invalid destination address", loggerObject); + const { + destinationAddressType: rawAddressType, + giftMessage: rawGiftMessage, + } = ctx.query; + + const destinationAddressType = rawAddressType + ? validateDestinationAddressType(ctx, rawAddressType) + : "arweave"; + if (!destinationAddressType) { return next(); } + const giftMessage = rawGiftMessage + ? validateGiftMessage(ctx, rawGiftMessage) + : undefined; + if (giftMessage === false) { + return next(); + } + + let destinationAddress: string; + if (destinationAddressType === "arweave") { + if (!isValidArweaveBase64URL(rawDestinationAddress)) { + ctx.response.status = 403; + ctx.body = "Destination address is not a valid Arweave native address!"; + logger.info("top-up GET -- Invalid destination address", loggerObject); + return next(); + } + + destinationAddress = rawDestinationAddress; + } else { + if (!isGiftingEnabled) { + ctx.response.status = 403; + ctx.body = "Gifting by email is disabled!"; + logger.info("top-up GET -- Gifting is disabled", loggerObject); + return next(); + } + + if (!validator.isEmail(rawDestinationAddress)) { + ctx.response.status = 400; + ctx.body = "Destination address is not a valid email!"; + logger.info("top-up GET -- Invalid destination address", loggerObject); + return next(); + } + + // Escape email address to prevent XSS + destinationAddress = validator.escape(rawDestinationAddress); + } + let currencyLimitations: CurrencyLimitations; try { @@ -94,7 +146,7 @@ export async function topUp(ctx: KoaContext, next: Next) { try { wincForPaymentResponse = await pricingService.getWCForPayment({ payment, - userAddress: destinationAddress, + userAddress: rawDestinationAddress, promoCodes, }); } catch (error) { @@ -131,7 +183,7 @@ export async function topUp(ctx: KoaContext, next: Next) { const topUpQuote: CreateTopUpQuoteParams = { topUpQuoteId: randomUUID(), - destinationAddressType: "arweave", + destinationAddressType, paymentAmount: actualPaymentAmount, quotedPaymentAmount, winstonCreditAmount: finalPrice.winc, @@ -140,6 +192,7 @@ export async function topUp(ctx: KoaContext, next: Next) { quoteExpirationDate: fiveMinutesFromNow, paymentProvider: "stripe", adjustments, + giftMessage, }; const { paymentProvider, adjustments: _a, ...stripeMetadataRaw } = topUpQuote; @@ -169,10 +222,27 @@ export async function topUp(ctx: KoaContext, next: Next) { metadata: stripeMetadata, }); } else { + const localGiftUrl = `http://localhost:5173`; + const prodGiftUrl = `https://gift.ardrive.io`; + const giftUrl = + process.env.NODE_ENV === "prod" || process.env.NODE_ENV === "dev" + ? prodGiftUrl + : localGiftUrl; + + const urlEncodedGiftMessage = giftMessage + ? encodeURIComponent(giftMessage) + : undefined; intentOrCheckout = await stripe.checkout.sessions.create({ // TODO: Success and Cancel URLS (Do we need app origin? e.g: ArDrive Widget, Top Up Page, ario-turbo-cli) success_url: "https://app.ardrive.io", - cancel_url: "https://app.ardrive.io", + cancel_url: + destinationAddressType === "email" + ? `${giftUrl}?email=${destinationAddress}&amount=${payment.amount}${ + urlEncodedGiftMessage + ? `&giftMessage=${urlEncodedGiftMessage}` + : "" + }` + : "https://app.ardrive.io", currency: payment.type, automatic_tax: { enabled: !!process.env.ENABLE_AUTO_STRIPE_TAX || false, diff --git a/src/server.ts b/src/server.ts index a7f71da..be7abfa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,9 +24,11 @@ import { Architecture } from "./architecture"; import { TEST_PRIVATE_ROUTE_SECRET, defaultPort, + isGiftingEnabled, migrateOnStartup, } from "./constants"; import { PostgresDatabase } from "./database/postgres"; +import { MandrillEmailProvider } from "./emailProvider"; import logger from "./logger"; import { MetricRegistry } from "./metricRegistry"; import { architectureMiddleware, loggerMiddleware } from "./middleware"; @@ -52,13 +54,13 @@ export async function createServer( await loadSecretsToEnv(); const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; + const MANDRILL_API_KEY = process.env.MANDRILL_API_KEY; const sharedSecret = process.env.PRIVATE_ROUTE_SECRET ?? TEST_PRIVATE_ROUTE_SECRET; if (!sharedSecret) { throw new Error("Shared secret not set"); } - if (!STRIPE_SECRET_KEY) { throw new Error("Stripe secret key or webhook secret not set"); } @@ -76,11 +78,26 @@ export async function createServer( const stripe = arch.stripe ?? new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2022-11-15" }); + const emailProvider = (() => { + if (!isGiftingEnabled) { + return undefined; + } + if (arch.emailProvider) { + return arch.emailProvider; + } + if (!MANDRILL_API_KEY) { + throw new Error( + "MANDRILL_API_KEY environment variable is not set! Please set the key and restart the server or set GIFTING_ENABLED=false to disable gifting by email on top ups flow." + ); + } + return new MandrillEmailProvider(MANDRILL_API_KEY, logger); + })(); app.use((ctx: KoaContext, next: Next) => architectureMiddleware(ctx, next, { pricingService, paymentDatabase, stripe, + emailProvider, }) ); diff --git a/src/triggerEmail.ts b/src/triggerEmail.ts new file mode 100644 index 0000000..3e7f601 --- /dev/null +++ b/src/triggerEmail.ts @@ -0,0 +1,57 @@ +/** + * Copyright (C) 2022-2023 Permanent Data Solutions, Inc. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { isGiftingEnabled } from "./constants"; +import { UnredeemedGift } from "./database/dbTypes"; +import { EmailProvider } from "./emailProvider"; +import logger from "./logger"; +import { MetricRegistry } from "./metricRegistry"; + +export async function triggerEmail( + unredeemedGift: UnredeemedGift, + emailProvider?: EmailProvider +): Promise { + try { + if (!emailProvider) { + throw Error( + "Email provider is not defined! Cannot send gift redemption email!" + ); + } + + if (!isGiftingEnabled) { + throw Error("Gifting is not enabled! Cannot send gift redemption email!"); + } + + const { + giftedWincAmount, + paymentReceiptId, + recipientEmail, + giftMessage, + senderEmail, + } = unredeemedGift; + + await emailProvider.sendEmail({ + credits: (+giftedWincAmount / 1_000_000_000_000).toFixed(4), + giftCode: paymentReceiptId, + recipientEmail, + giftMessage, + senderEmail, + }); + } catch (error) { + MetricRegistry.giftEmailTriggerFailure.inc(); + logger.error("❌ Email sending has failed!", error, unredeemedGift); + } +} diff --git a/src/utils/loadSecretsToEnv.ts b/src/utils/loadSecretsToEnv.ts index 1292760..6adbb2c 100644 --- a/src/utils/loadSecretsToEnv.ts +++ b/src/utils/loadSecretsToEnv.ts @@ -30,6 +30,7 @@ const jwtSecretName = "jwt-secret"; const dbPasswordSecretName = "payment-db-password"; const wincSubsidizedPercentageParamName = "/payment-service/subsidized-winc-percentage"; +const mandrillApiKeySecretName = "mandrill-api-key"; export async function loadSecretsToEnv() { try { @@ -83,6 +84,9 @@ export async function loadSecretsToEnv() { ); process.env.JWT_SECRET ??= await getSecretValueCommand(jwtSecretName); process.env.DB_PASSWORD ??= await getSecretValueCommand(dbPasswordSecretName); + process.env.MANDRILL_API_KEY ??= await getSecretValueCommand( + mandrillApiKeySecretName + ); process.env.SUBSIDIZED_WINC_PERCENTAGE ??= await getSSMParameterCommand( wincSubsidizedPercentageParamName diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 7f75b8a..77715df 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -14,6 +14,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import validator from "validator"; + +import { maxGiftMessageLength } from "../constants"; +import { + DestinationAddressType, + destinationAddressTypes, +} from "../database/dbTypes"; import { MetricRegistry } from "../metricRegistry"; import { KoaContext } from "../server"; import { ByteCount, Winston } from "../types"; @@ -90,3 +97,69 @@ export function validateWinstonCreditAmount( return false; } } + +export function validateSingularQueryParameter( + ctx: KoaContext, + queryParameter: string | string[] | undefined +): string | false { + if ( + !queryParameter || + (Array.isArray(queryParameter) && queryParameter.length > 1) + ) { + ctx.response.status = 400; + ctx.body = "Invalid or missing parameters"; + ctx.state.logger.error("Invalid parameters provided for route!", { + query: ctx.query, + params: ctx.params, + }); + return false; + } + + return Array.isArray(queryParameter) ? queryParameter[0] : queryParameter; +} + +function isDestinationAddressType( + destinationAddressType: string +): destinationAddressType is DestinationAddressType { + return destinationAddressTypes.includes( + destinationAddressType as DestinationAddressType + ); +} + +export function validateDestinationAddressType( + ctx: KoaContext, + destinationAddressType: string | string[] +): DestinationAddressType | false { + const destType = validateSingularQueryParameter(ctx, destinationAddressType); + + if (!destType || !isDestinationAddressType(destType)) { + ctx.response.status = 400; + ctx.body = `Invalid destination address type: ${destType}`; + ctx.state.logger.error("Invalid destination address type!", { + ...ctx.params, + ...ctx.query, + }); + return false; + } + + return destType; +} + +export function validateGiftMessage( + ctx: KoaContext, + giftMessage: string | string[] +): string | false { + const message = validateSingularQueryParameter(ctx, giftMessage); + + if (!message || message.length > maxGiftMessageLength) { + ctx.response.status = 400; + ctx.body = "Invalid gift message!"; + ctx.state.logger.error("Invalid gift message!", { + query: ctx.query, + params: ctx.params, + }); + return false; + } + + return validator.escape(message); +} diff --git a/tests/dbTestHelper.ts b/tests/dbTestHelper.ts index b1db6d2..0b48b78 100644 --- a/tests/dbTestHelper.ts +++ b/tests/dbTestHelper.ts @@ -31,8 +31,10 @@ import { PostgresDatabase } from "../src/database/postgres"; export const stubArweaveUserAddress: UserAddress = "1234567890123456789012345678901231234567890"; -const oneHourAgo = new Date(Date.now() - 1000 * 60 * 60).toISOString(); -const oneHourFromNow = new Date(Date.now() + 1000 * 60 * 60).toISOString(); +export const oneHourAgo = new Date(Date.now() - 1000 * 60 * 60).toISOString(); +export const oneHourFromNow = new Date( + Date.now() + 1000 * 60 * 60 +).toISOString(); type StubTopUpQuoteParams = Partial; @@ -46,6 +48,7 @@ function stubTopUpQuoteInsert({ winston_credit_amount = "1337", quote_expiration_date = oneHourFromNow, payment_provider = "stripe", + gift_message, }: StubTopUpQuoteParams): TopUpQuoteDBInsert { return { top_up_quote_id, @@ -57,6 +60,7 @@ function stubTopUpQuoteInsert({ winston_credit_amount, quote_expiration_date, payment_provider, + gift_message, }; } diff --git a/tests/helpers/testHelpers.ts b/tests/helpers/testHelpers.ts index cc20ef7..321f538 100644 --- a/tests/helpers/testHelpers.ts +++ b/tests/helpers/testHelpers.ts @@ -21,6 +21,7 @@ import Stripe from "stripe"; import { createAxiosInstance } from "../../src/axiosClient"; import { PostgresDatabase } from "../../src/database/postgres"; +import { MandrillEmailProvider } from "../../src/emailProvider"; import { CoingeckoArweaveToFiatOracle, ReadThroughArweaveToFiatOracle, @@ -115,6 +116,8 @@ export const axios = axiosPackage.create({ baseURL: localTestUrl, validateStatus: () => true, }); +export const emailProvider = new MandrillEmailProvider("test"); + export const testAddress = "-kYy3_LcYeKhtqNNXDN6xTQ7hW8S5EV0jgq_6j8a830"; // cspell:disable-line export function removeCatalogIdMap( diff --git a/tests/router.int.test.ts b/tests/router.int.test.ts index 062cf2b..2378873 100644 --- a/tests/router.int.test.ts +++ b/tests/router.int.test.ts @@ -30,9 +30,14 @@ import { import { tableNames } from "../src/database/dbConstants"; import { ChargebackReceiptDBResult, + PaymentReceiptDBInsert, PaymentReceiptDBResult, + RedeemedGiftDBResult, SingleUseCodePaymentCatalogDBResult, TopUpQuote, + TopUpQuoteDBResult, + UnredeemedGiftDBInsert, + UnredeemedGiftDBResult, UserDBResult, } from "../src/database/dbTypes.js"; import logger from "../src/logger"; @@ -47,6 +52,7 @@ import { supportedPaymentCurrencyTypes } from "../src/types/supportedCurrencies" import { Winston } from "../src/types/winston"; import { arweaveRSAModulusToAddress } from "../src/utils/jwkUtils"; import { signedRequestHeadersFromJwk } from "../tests/helpers/signData"; +import { oneHourAgo, oneHourFromNow } from "./dbTestHelper"; import { chargeDisputeStub, checkoutSessionStub, @@ -61,9 +67,9 @@ import { import { assertExpectedHeadersWithContentLength } from "./helpers/testExpectations"; import { axios, - coinGeckoAxios, coinGeckoOracle, dbTestHelper, + emailProvider, localTestUrl, paymentDatabase, pricingService, @@ -83,8 +89,24 @@ describe("Router tests", () => { const routerTestPromoCode = "routerTestPromoCode"; const routerTestPromoCodeCatalogId = "routerTestPromoCodeCatalogId"; + beforeEach(() => { + stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( + expectedArPrices.arweave + ); + }); + before(async () => { - server = await createServer({ pricingService, paymentDatabase, stripe }); + await dbTestHelper.insertStubUser({ + user_address: testAddress, + winston_credit_balance: "5000000", + }); + + server = await createServer({ + pricingService, + paymentDatabase, + stripe, + emailProvider, + }); await paymentDatabase["writer"]( tableNames.singleUseCodePaymentAdjustmentCatalog ).insert({ @@ -192,14 +214,7 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value returns 502 if fiat pricing oracle response is unexpected", async () => { - stub(coinGeckoAxios, "get").resolves({ - data: { - arweave: { - weird: "types", - from: ["c", 0, "in", "ge", "ck", 0], - }, - }, - }); + stub(pricingService, "getWCForPayment").throws(); const { data, status, statusText } = await axios.get(`/v1/price/usd/5000`); expect(status).to.equal(502); @@ -208,7 +223,8 @@ describe("Router tests", () => { }); it("GET /rates returns 502 if unable to fetch prices", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").throws(); + stub(pricingService, "getWCForBytes").throws(Error("Serious failure")); + const { status, statusText } = await axios.get(`/v1/rates`); expect(status).to.equal(502); @@ -221,9 +237,6 @@ describe("Router tests", () => { ); const clock = useFakeTimers(fakeDateBeforeSubsidyAndInfraFee.getTime()); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { data, status, statusText } = await axios.get(`/v1/rates`); expect(status).to.equal(200); @@ -256,9 +269,6 @@ describe("Router tests", () => { ); const clock = useFakeTimers(fakeDateDuringTwentyPctInfraFee.getTime()); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { data, status, statusText } = await axios.get(`/v1/rates`); expect(status).to.equal(200); @@ -291,9 +301,6 @@ describe("Router tests", () => { fakeDateDuringTwentyThreeFourPctInfraFeeAndSepOctSubsidyEvent.getTime() ); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { data, status, statusText } = await axios.get(`/v1/rates`); expect(status).to.equal(200); @@ -334,9 +341,6 @@ describe("Router tests", () => { }); it("GET /rates/:currency returns the correct response for supported currency", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { data, status, statusText } = await axios.get(`/v1/rates/usd`); expect(status).to.equal(200); @@ -349,9 +353,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { status, statusText, data } = await axios.get(`/v1/price/USD/100`); expect(status).to.equal(200); @@ -383,9 +384,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value with a 20% off promoCode in query params returns expected result", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { status, statusText, data } = await axiosPackage .create({ baseURL: localTestUrl, @@ -434,10 +432,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value with a 20% off promoCode and destinationAddress in query params returns expected result", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); - const destinationAddress = "43CharactersABCDEFGHIJKLMNOPQRSTUVWXYZ12345"; const { status, statusText, data } = await axiosPackage .create({ @@ -487,9 +481,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value with duplicate 20% off promoCodes in query params returns expected result", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { status, statusText, data } = await axiosPackage .create({ baseURL: localTestUrl, @@ -541,9 +532,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value with INVALID promoCode in query params returns a 400", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { status, statusText, data } = await axiosPackage .create({ baseURL: localTestUrl, @@ -559,9 +547,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value with INELIGIBLE promoCode in query params returns a 400", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const jwk = await Arweave.crypto.generateJWK(); const userAddress = arweaveRSAModulusToAddress(jwk.n); @@ -594,9 +579,6 @@ describe("Router tests", () => { }); it("GET /price/:currency/:value with promoCode in query params but an unauthenticated request and lacking a destinationAddress returns a 400", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { status, statusText, data } = await axiosPackage .create({ baseURL: localTestUrl, @@ -640,13 +622,6 @@ describe("Router tests", () => { expect(data).to.equal("Fiat Oracle Unavailable"); }); - before(async () => { - await dbTestHelper.insertStubUser({ - user_address: testAddress, - winston_credit_balance: "5000000", - }); - }); - it("GET /balance returns 200 for correct signature", async () => { const { status, statusText, data } = await axios.get(`/v1/balance`, { headers: await signedRequestHeadersFromJwk(testWallet, "123"), @@ -699,16 +674,118 @@ describe("Router tests", () => { expect(data).to.equal("Cloud Database Unavailable"); }); - it("GET /top-up/checkout-session returns 200 and correct response for correct signature", async () => { + it("GET /top-up/checkout-session with an email in query params returns the correct response", async () => { const amount = 1000; + const email = "test@example.inc"; const checkoutStub = stub(stripe.checkout.sessions, "create").resolves( stripeResponseStub({ ...checkoutSessionSuccessStub, amount_total: amount, }) ); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave + + const { status, statusText, data } = await axios.get( + `/v1/top-up/checkout-session/${email}/usd/${amount}?destinationAddressType=email&giftMessage=hello%20world` + ); + + expect(data).to.have.property("topUpQuote"); + expect(data).to.have.property("paymentSession"); + expect(status).to.equal(200); + expect(statusText).to.equal("OK"); + + const { paymentSession, topUpQuote, adjustments, fees } = data; + const { object, payment_method_types, amount_total, url } = paymentSession; + + expect(object).to.equal("checkout.session"); + expect(payment_method_types).to.deep.equal(["card"]); + expect(amount_total).to.equal(amount); + expect(url).to.be.a.string; + + const { + quotedPaymentAmount, + paymentAmount, + topUpQuoteId, + destinationAddress, + destinationAddressType, + quoteExpirationDate, + } = topUpQuote; + + expect(quotedPaymentAmount).to.equal(1000); + expect(paymentAmount).to.equal(1000); + expect(topUpQuoteId).to.be.a.string; + expect(destinationAddress).to.equal(email); + expect(destinationAddressType).to.equal("email"); + expect(quoteExpirationDate).to.be.a.string; + + expect(fees).to.deep.equal([ + { + adjustmentAmount: -234, + currencyType: "usd", + description: + "Inclusive usage fee on all payments to cover infrastructure costs and payment provider fees.", + name: "Turbo Infrastructure Fee", + operator: "multiply", + operatorMagnitude: 0.766, + }, + ]); + expect(adjustments).to.deep.equal([]); + + const dbResult = await paymentDatabase["writer"]( + tableNames.topUpQuote + ) + .where({ top_up_quote_id: topUpQuoteId }) + .first(); + + expect(dbResult).to.not.be.undefined; + const { + currency_type, + top_up_quote_id, + payment_provider, + quoted_payment_amount, + payment_amount, + destination_address, + destination_address_type, + winston_credit_amount, + quote_expiration_date, + quote_creation_date, + gift_message, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + } = dbResult!; + + expect(currency_type).to.equal("usd"); + expect(top_up_quote_id).to.be.a.string; + expect(payment_provider).to.equal("stripe"); + expect(quoted_payment_amount).to.equal("1000"); + expect(payment_amount).to.equal("1000"); + expect(destination_address).to.equal(email); + expect(destination_address_type).to.equal("email"); + expect(winston_credit_amount).to.equal("1091168091168"); + expect(new Date(quote_expiration_date).toISOString()).to.equal( + quoteExpirationDate.toString() + ); + expect(quote_creation_date).to.be.a.string; + expect(gift_message).to.equal("hello world"); + + checkoutStub.restore(); + }); + + it("GET /top-up/checkout-session with an invalid destination address type returns 400 response", async () => { + const { status, statusText, data } = await axios.get( + `/v1/top-up/checkout-session/hello-test/usd/4231?destinationAddressType=notReal` + ); + + expect(status).to.equal(400); + expect(statusText).to.equal("Bad Request"); + expect(data).to.equal("Invalid destination address type: notReal"); + }); + + it("GET /top-up/checkout-session returns 200 and correct response for correct signature", async () => { + const amount = 1000; + const checkoutStub = stub(stripe.checkout.sessions, "create").resolves( + stripeResponseStub({ + ...checkoutSessionSuccessStub, + amount_total: amount, + }) ); const { status, statusText, data } = await axios.get( @@ -741,10 +818,6 @@ describe("Router tests", () => { }) ); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); - const { status, statusText, data } = await axios.get( `/v1/top-up/payment-intent/${testAddress}/usd/${topUpAmount}` ); @@ -811,10 +884,6 @@ describe("Router tests", () => { }) ); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); - const { status, statusText, data } = await axiosPackage .create({ baseURL: localTestUrl, @@ -889,9 +958,6 @@ describe("Router tests", () => { }); it("GET /top-up with INVALID promoCode in query params returns a 400", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const { status, statusText, data } = await axiosPackage .create({ baseURL: localTestUrl, @@ -910,9 +976,6 @@ describe("Router tests", () => { }); it("GET /top-up with INELIGIBLE promoCode in query params returns a 400", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); const jwk = await Arweave.crypto.generateJWK(); const userAddress = arweaveRSAModulusToAddress(jwk.n); @@ -958,6 +1021,15 @@ describe("Router tests", () => { expect(statusText).to.equal("Forbidden"); }); + it("GET /top-up returns 400 for bad email address", async () => { + const { status, statusText, data } = await axios.get( + `/v1/top-up/checkout-session/THISisNotEmail/usd/100?destinationAddressType=email` + ); + expect(status).to.equal(400); + expect(data).to.equal("Destination address is not a valid email!"); + expect(statusText).to.equal("Bad Request"); + }); + it("GET /top-up returns 400 for invalid payment method", async () => { const { status, data, statusText } = await axios.get( `/v1/top-up/some-method/${testAddress}/usd/101` @@ -1019,9 +1091,6 @@ describe("Router tests", () => { stripe.checkout.sessions, "create" ).resolves(stripeResponseStub(checkoutSessionStub({}))); - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); // Get maximum price for each supported currency concurrently const maxPriceResponses = await Promise.all( @@ -1050,10 +1119,6 @@ describe("Router tests", () => { }); it("GET /top-up returns 400 for a payment amount too large in each supported currency", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); - for (const currencyType of supportedPaymentCurrencyTypes) { const maxAmountAllowed = currencyLimitations[currencyType].maximumPaymentAmount; @@ -1075,10 +1140,6 @@ describe("Router tests", () => { }); it("GET /top-up returns 400 for a payment amount too small in each supported currency", async () => { - stub(coinGeckoOracle, "getFiatPricesForOneAR").resolves( - expectedArPrices.arweave - ); - for (const currencyType of supportedPaymentCurrencyTypes) { const minAmountAllowed = currencyLimitations[currencyType].minimumPaymentAmount; @@ -1557,6 +1618,113 @@ describe("Router tests", () => { expect(destination_address_type).to.equal("arweave"); expect(payment_provider).to.equal("stripe"); + const user = await paymentDatabase["reader"]( + tableNames.user + ).where({ + user_address: paymentReceivedUserAddress, + }); + expect(user[0].winston_credit_balance).to.equal("500"); + + webhookStub.restore(); + }); + + it("POST /stripe-webhook returns 200 for valid stripe payment success event resulting in an unredeemed gift and the database contains the correct payment receipt and unredeemed gift entities", async () => { + stub(emailProvider, "sendEmail").resolves(); + + const paymentReceivedEventId = "A Unique ID!!!"; + const testEmailAddress = "test@example.inc"; + const paymentSuccessTopUpQuoteId = "0x0987654321091"; + + await dbTestHelper.insertStubTopUpQuote({ + top_up_quote_id: paymentSuccessTopUpQuoteId, + winston_credit_amount: "500", + payment_amount: "100", + quoted_payment_amount: "100", + destination_address: testEmailAddress, + destination_address_type: "email", + gift_message: "A gift message", + }); + + const successStub = paymentIntentStub({ + id: paymentReceivedEventId, + metadata: { + topUpQuoteId: paymentSuccessTopUpQuoteId, + }, + amount: 100, + currency: "usd", + }); + + const stubEvent = stripeStubEvent({ + type: "payment_intent.succeeded", + eventObject: successStub, + }); + + const webhookStub = stub(stripe.webhooks, "constructEvent").returns( + stubEvent + ); + + const { status, statusText, data } = await axios.post(`/v1/stripe-webhook`); + + expect(status).to.equal(200); + expect(statusText).to.equal("OK"); + expect(data).to.equal("OK"); + + // wait a few seconds for the database to update since we return the response right away + await new Promise((resolve) => setTimeout(resolve, 500)); + + const paymentReceipt = await paymentDatabase[ + "writer" + ](tableNames.paymentReceipt).where({ + top_up_quote_id: paymentSuccessTopUpQuoteId, + }); + expect(paymentReceipt.length).to.equal(1); + + const { + payment_amount, + quoted_payment_amount, + currency_type, + destination_address, + destination_address_type, + payment_provider, + } = paymentReceipt[0]; + + expect(payment_amount).to.equal("100"); + expect(quoted_payment_amount).to.equal("100"); + expect(currency_type).to.equal("usd"); + expect(destination_address).to.equal(testEmailAddress); + expect(destination_address_type).to.equal("email"); + expect(payment_provider).to.equal("stripe"); + + const gift = await paymentDatabase["writer"]( + tableNames.unredeemedGift + ).where({ + payment_receipt_id: paymentReceipt[0].payment_receipt_id, + }); + expect(gift.length).to.equal(1); + + const { + creation_date, + expiration_date, + gifted_winc_amount, + payment_receipt_id, + recipient_email, + gift_message, + } = gift[0]; + + expect(creation_date).to.exist; + // expect expiration to be gift creation plus 1 year + expect(new Date(expiration_date).toISOString()).to.equal( + new Date( + new Date(creation_date).setFullYear( + new Date(creation_date).getFullYear() + 1 + ) + ).toISOString() + ); + expect(gifted_winc_amount).to.equal("500"); + expect(payment_receipt_id).to.equal(paymentReceipt[0].payment_receipt_id); + expect(recipient_email).to.equal(testEmailAddress); + expect(gift_message).to.equal("A gift message"); + webhookStub.restore(); }); @@ -1569,6 +1737,210 @@ describe("Router tests", () => { expect(statusText).to.equal("Bad Request"); expect(data).to.equal("Webhook Error!"); }); + + it("GET /rates returns 502 if unable to fetch prices", async () => { + stub(pricingService, "getWCForBytes").throws(Error("Serious failure")); + + const { status, statusText } = await axios.get(`/v1/rates`); + + expect(status).to.equal(502); + expect(statusText).to.equal("Bad Gateway"); + }); + + it("GET /redeem returns 200 for valid params", async () => { + const destinationAddress = "validArweaveAddressNeedsFortyThreeCharacter"; + const paymentReceiptId = "unique paymentReceiptId"; + const emailAddress = "test@example.inc"; + const giftMessage = "hello the world!"; + + const paymentReceiptDBInsert: PaymentReceiptDBInsert = { + top_up_quote_id: "required top up id", + payment_receipt_id: paymentReceiptId, + payment_amount: "100", + quoted_payment_amount: "100", + currency_type: "email", + destination_address: emailAddress, + destination_address_type: "arweave", + payment_provider: "stripe", + quote_creation_date: oneHourAgo, + quote_expiration_date: oneHourFromNow, + winston_credit_amount: "100", + gift_message: giftMessage, + }; + await paymentDatabase["writer"]( + tableNames.paymentReceipt + ).insert(paymentReceiptDBInsert); + const unredeemedGiftDbInsert: UnredeemedGiftDBInsert = { + gifted_winc_amount: "100", + payment_receipt_id: paymentReceiptId, + recipient_email: emailAddress, + gift_message: giftMessage, + }; + await paymentDatabase["writer"]( + tableNames.unredeemedGift + ).insert(unredeemedGiftDbInsert); + + const { status, statusText, data } = await axios.get( + `/v1/redeem?destinationAddress=${destinationAddress}&id=${paymentReceiptId}&email=${emailAddress}` + ); + + expect(status).to.equal(200); + expect(statusText).to.equal("OK"); + + const { message, userBalance, userAddress, userCreationDate } = data; + expect(message).to.equal("Payment receipt redeemed for 100 winc!"); + expect(userBalance).to.equal("100"); + expect(userAddress).to.equal(destinationAddress); + expect(userCreationDate).to.exist; + + const userDbResult = await paymentDatabase["reader"]( + tableNames.user + ).where({ + user_address: destinationAddress, + }); + expect(userDbResult.length).to.equal(1); + expect(userDbResult[0].winston_credit_balance).to.equal("100"); + + const unredeemedGiftDbResult = await paymentDatabase[ + "reader" + ](tableNames.unredeemedGift).where({ + payment_receipt_id: paymentReceiptId, + }); + expect(unredeemedGiftDbResult.length).to.equal(0); + + const redeemedGiftDbResult = await paymentDatabase[ + "reader" + ](tableNames.redeemedGift).where({ + payment_receipt_id: paymentReceiptId, + }); + expect(redeemedGiftDbResult.length).to.equal(1); + + const { + payment_receipt_id, + recipient_email, + gift_message, + creation_date, + destination_address, + expiration_date, + gifted_winc_amount, + redemption_date, + } = redeemedGiftDbResult[0]; + + expect(payment_receipt_id).to.equal(paymentReceiptId); + expect(recipient_email).to.equal(emailAddress); + expect(gift_message).to.equal(giftMessage); + expect(creation_date).to.exist; + expect(destination_address).to.equal(destinationAddress); + expect(expiration_date).to.exist; + expect(gifted_winc_amount).to.equal("100"); + expect(redemption_date).to.exist; + }); + + it("GET /redeem returns 400 for invalid email", async () => { + const destinationAddress = "validArweaveAddressNeedsFortyThreeCharacter"; + const paymentReceiptId = "unique paymentReceiptId 21e12"; + const emailAddress = "invalid email"; + + const { status, statusText, data } = await axios.get( + `/v1/redeem?destinationAddress=${destinationAddress}&id=${paymentReceiptId}&email=${emailAddress}` + ); + + expect(status).to.equal(400); + expect(statusText).to.equal("Bad Request"); + expect(data).to.equal( + "Provided recipient email address is not a valid email!" + ); + }); + + it("GET /redeem returns 400 for invalid destination address", async () => { + const destinationAddress = "invalidArweaveAddressNeedsFortyThreeCharacter"; + const paymentReceiptId = "unique das paymentReceiptId 21e12"; + const emailAddress = "fake@example.inc"; + + const { status, statusText, data } = await axios.get( + `/v1/redeem?destinationAddress=${destinationAddress}&id=${paymentReceiptId}&email=${emailAddress}` + ); + + expect(status).to.equal(400); + expect(statusText).to.equal("Bad Request"); + expect(data).to.equal( + "Provided destination address is not a valid Arweave native address!" + ); + }); + + it("GET /redeem returns 400 for non matching recipient email", async () => { + const destinationAddress = "validArweaveAddressNeedsFortyThreeCharacter"; + const paymentReceiptId = "unique paymentReceiptId 231"; + const emailAddress = "fake@inc.com"; + + const paymentReceiptDBInsert: PaymentReceiptDBInsert = { + top_up_quote_id: "required top up id!", + payment_receipt_id: paymentReceiptId, + payment_amount: "100", + quoted_payment_amount: "100", + currency_type: "email", + destination_address: emailAddress, + destination_address_type: "arweave", + payment_provider: "stripe", + quote_creation_date: oneHourAgo, + quote_expiration_date: oneHourFromNow, + winston_credit_amount: "100", + gift_message: "A gift message", + }; + await paymentDatabase["writer"]( + tableNames.paymentReceipt + ).insert(paymentReceiptDBInsert); + + const unredeemedGiftDbInsert: UnredeemedGiftDBInsert = { + gifted_winc_amount: "100", + payment_receipt_id: paymentReceiptId, + recipient_email: emailAddress, + gift_message: "A gift message", + }; + await paymentDatabase["writer"]( + tableNames.unredeemedGift + ).insert(unredeemedGiftDbInsert); + + const { status, statusText, data } = await axios.get( + `/v1/redeem?destinationAddress=${destinationAddress}&id=${paymentReceiptId}&email=wrong@email.test` + ); + + expect(status).to.equal(400); + expect(statusText).to.equal("Bad Request"); + expect(data).to.equal("Failure to redeem payment receipt!"); + }); + + it("GET /redeem returns 400 for not found payment receipt id", async () => { + const destinationAddress = "validArweaveAddressNeedsFortyThreeCharacter"; + const paymentReceiptId = "unique paymentReceiptId 221"; + const emailAddress = "fake@unique.inc"; + + const { status, statusText, data } = await axios.get( + `/v1/redeem?destinationAddress=${destinationAddress}&id=${paymentReceiptId}&email=${emailAddress}` + ); + + expect(status).to.equal(400); + expect(statusText).to.equal("Bad Request"); + expect(data).to.equal("Failure to redeem payment receipt!"); + }); + + it("GET /redeem returns 503 for unexpected database error", async () => { + const destinationAddress = "validArweaveAddressNeedsFortyThreeCharacter"; + const paymentReceiptId = "unique paymentReceiptId 141"; + const emailAddress = "fake@unique.inc"; + + stub(paymentDatabase, "redeemGift").throws(); + + const { status, statusText, data } = await axios.get( + `/v1/redeem?destinationAddress=${destinationAddress}&id=${paymentReceiptId}&email=${emailAddress}` + ); + + expect(status).to.equal(503); + expect(statusText).to.equal("Service Unavailable"); + expect(data).to.equal( + "Error while redeeming payment receipt. Unable to reach Database!" + ); + }); }); describe("Caching behavior tests", () => { diff --git a/tests/testSetup.ts b/tests/testSetup.ts index 7dd8fe8..34e8a75 100644 --- a/tests/testSetup.ts +++ b/tests/testSetup.ts @@ -21,6 +21,8 @@ process.env.PORT ??= "1234"; process.env.DISABLE_LOGS ??= "true"; process.env.STRIPE_SECRET_KEY ??= "test"; process.env.STRIPE_WEBHOOK_SECRET ??= "test"; +process.env.MANDRILL_API_KEY ??= "test"; +process.env.GIFTING_ENABLED ??= "true"; // Restores the default sandbox after every test exports.mochaHooks = { diff --git a/yarn.lock b/yarn.lock index 528b49d..babf72b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1859,6 +1859,15 @@ __metadata: languageName: node linkType: hard +"@types/mandrill-api@npm:^1.0.33": + version: 1.0.33 + resolution: "@types/mandrill-api@npm:1.0.33" + dependencies: + "@types/node": "*" + checksum: e739cb330faa54533fe096ab5a27ba509c956db7ae14e9a336f00d48bd51ea15abde9f477d578263880c5fdb7bd651012db277d8901f542ce77b088c49f8a338 + languageName: node + linkType: hard + "@types/mime@npm:*": version: 3.0.1 resolution: "@types/mime@npm:3.0.1" @@ -1976,6 +1985,13 @@ __metadata: languageName: node linkType: hard +"@types/validator@npm:^13.11.7": + version: 13.11.7 + resolution: "@types/validator@npm:13.11.7" + checksum: 975ad31728f3e32278f090545b879453d5d2b26dd159c6b632efb79e748711bca15e6339b93e85c33b48208b1aee262d3043246118aa3c67a74fb0f89700b1d5 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^5.25.0": version: 5.62.0 resolution: "@typescript-eslint/eslint-plugin@npm:5.62.0" @@ -4895,6 +4911,13 @@ __metadata: languageName: node linkType: hard +"mandrill-api@npm:^1.0.45": + version: 1.0.45 + resolution: "mandrill-api@npm:1.0.45" + checksum: 25803951a1113cb597c2ced36596f9f10e43831e5b219c3b93c73b5567362e802a65256665f4cf138f89a60f0c3bb155ba839d6cb24111eeb21c3ebc90a38607 + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -5637,10 +5660,12 @@ __metadata: "@types/koa": ^2.13.4 "@types/koa-router": ^7.4.4 "@types/koa__cors": ^3.3.0 + "@types/mandrill-api": ^1.0.33 "@types/mocha": ^9.1.1 "@types/node": ^18.16.1 "@types/sinon": ^10.0.11 "@types/sinon-chai": ^3.2.9 + "@types/validator": ^13.11.7 "@typescript-eslint/eslint-plugin": ^5.25.0 "@typescript-eslint/parser": ^5.25.0 arweave: ^1.13.4 @@ -5660,6 +5685,7 @@ __metadata: koa-router: 11.0.1 koa2-swagger-ui: ^5.8.0 lint-staged: ^12.5.0 + mandrill-api: ^1.0.45 mocha: ^10.0.0 nodemon: ^3.0.1 nyc: ^15.1.0 @@ -5674,6 +5700,7 @@ __metadata: stripe: ^11.13.0 ts-node: ^10.7.0 typescript: ^4.7.4 + validator: ^13.11.0 winston: ^3.8.2 yaml: ^2.2.2 languageName: unknown @@ -6964,6 +6991,13 @@ __metadata: languageName: node linkType: hard +"validator@npm:^13.11.0": + version: 13.11.0 + resolution: "validator@npm:13.11.0" + checksum: d1e0c27022681420756da25bc03eb08d5f0c66fb008f8ff02ebc95812b77c6be6e03d3bd05cf80ca702e23eeb73dadd66b4b3683173ea2a0bc7cc72820bee131 + languageName: node + linkType: hard + "vary@npm:^1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2"