diff --git a/bats/core/api/referral.bats b/bats/core/api/referral.bats new file mode 100644 index 00000000000..f6ced1d6853 --- /dev/null +++ b/bats/core/api/referral.bats @@ -0,0 +1,9 @@ +#!/usr/bin/env bats +load "../../helpers/user.bash" + +@test "referral: alice is using bob referral" { + local token_name=$1 + local phone=$(random_phone) + + login_user "$token_name" "$phone" "bob" +} diff --git a/bats/helpers/user.bash b/bats/helpers/user.bash index e680ed4f3f2..42723337335 100644 --- a/bats/helpers/user.bash +++ b/bats/helpers/user.bash @@ -1,10 +1,12 @@ CURRENT_FILE=${BASH_SOURCE:-bats/helpers/.} +echo "Sourcing file: $CURRENT_FILE" source "$(dirname "$CURRENT_FILE")/_common.bash" source "$(dirname "$CURRENT_FILE")/cli.bash" login_user() { local token_name=$1 local phone=$2 + local referral=$3 local code="000000" @@ -13,7 +15,8 @@ login_user() { jq -n \ --arg phone "$phone" \ --arg code "$code" \ - '{input: {phone: $phone, code: $code}}' + --arg referral "$referral" \ + '{input: {phone: $phone, code: $code, referral: $referral}}' ) exec_graphql 'anon' 'user-login' "$variables" auth_token="$(graphql_output '.data.userLogin.authToken')" diff --git a/core/api/Makefile b/core/api/Makefile index 0bc0fe3265c..640380c5f42 100644 --- a/core/api/Makefile +++ b/core/api/Makefile @@ -135,8 +135,5 @@ redis-cli: redis-flush: docker-compose exec redis redis-cli FLUSHDB -codegen: - pnpm run write-sdl - gen-test-jwt: pnpm run gen-test-jwt diff --git a/core/api/src/app/accounts/create-account.ts b/core/api/src/app/accounts/create-account.ts index 1ef58a51b1a..b23a901e631 100644 --- a/core/api/src/app/accounts/create-account.ts +++ b/core/api/src/app/accounts/create-account.ts @@ -13,10 +13,12 @@ const initializeCreatedAccount = async ({ account, config, phone, + referral, }: { account: Account config: AccountsConfig phone?: PhoneNumber + referral: Referral | undefined }): Promise => { const newWallet = (currency: WalletCurrency) => WalletsRepository().persistNew({ @@ -53,6 +55,7 @@ const initializeCreatedAccount = async ({ account.statusHistory = [{ status: config.initialStatus, comment: "Initial Status" }] account.level = config.initialLevel + account.referral = referral const updatedAccount = await AccountsRepository().update(account) if (updatedAccount instanceof Error) return updatedAccount @@ -79,6 +82,7 @@ export const createAccountForDeviceAccount = async ({ return initializeCreatedAccount({ account: accountNew, config: levelZeroAccountsConfig, + referral: undefined, }) } @@ -86,10 +90,12 @@ export const createAccountWithPhoneIdentifier = async ({ newAccountInfo: { kratosUserId, phone }, config, phoneMetadata, + referral, }: { newAccountInfo: NewAccountWithPhoneIdentifier config: AccountsConfig phoneMetadata?: PhoneMetadata + referral: Referral | undefined }): Promise => { const user = await UsersRepository().update({ id: kratosUserId, phone, phoneMetadata }) if (user instanceof Error) return user @@ -101,6 +107,7 @@ export const createAccountWithPhoneIdentifier = async ({ account: accountNew, config, phone, + referral, }) if (account instanceof Error) return account diff --git a/core/api/src/app/authentication/create-account-from-registration-payload.ts b/core/api/src/app/authentication/create-account-from-registration-payload.ts index ec32101bf83..be9e5e207ee 100644 --- a/core/api/src/app/authentication/create-account-from-registration-payload.ts +++ b/core/api/src/app/authentication/create-account-from-registration-payload.ts @@ -49,11 +49,12 @@ export const createAccountFromRegistrationPayload = async ({ return regPayload } - const { userId, phone, phoneMetadata } = regPayload + const { userId, phone, phoneMetadata, referral } = regPayload const account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId: userId }, config: getDefaultAccountsConfig(), phoneMetadata, + referral, }) if (account instanceof Error) { recordExceptionInCurrentSpan({ diff --git a/core/api/src/app/authentication/login.ts b/core/api/src/app/authentication/login.ts index d6ddfd8587f..4894e4b7215 100644 --- a/core/api/src/app/authentication/login.ts +++ b/core/api/src/app/authentication/login.ts @@ -62,10 +62,12 @@ export const loginWithPhoneToken = async ({ phone, code, ip, + referral, }: { phone: PhoneNumber code: PhoneCode ip: IpAddress + referral?: Referral }): Promise => { { const limitOk = await checkFailedLoginAttemptPerIpLimits(ip) @@ -103,6 +105,7 @@ export const loginWithPhoneToken = async ({ const kratosResult = await authService.createIdentityWithSession({ phone, phoneMetadata, + referral, }) if (kratosResult instanceof Error) return kratosResult diff --git a/core/api/src/app/bootstrap/index.ts b/core/api/src/app/bootstrap/index.ts index cf329f1f603..7c563477b0a 100644 --- a/core/api/src/app/bootstrap/index.ts +++ b/core/api/src/app/bootstrap/index.ts @@ -70,6 +70,7 @@ export const bootstrap = async () => { account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) } if (account instanceof Error) return account diff --git a/core/api/src/domain/accounts/index.types.d.ts b/core/api/src/domain/accounts/index.types.d.ts index 2a904c95b3e..3194726cd55 100644 --- a/core/api/src/domain/accounts/index.types.d.ts +++ b/core/api/src/domain/accounts/index.types.d.ts @@ -112,6 +112,7 @@ type Account = { notificationSettings: NotificationSettings kratosUserId: UserId displayCurrency: DisplayCurrency + referral?: Referral // temp role?: string } diff --git a/core/api/src/domain/authentication/index.types.d.ts b/core/api/src/domain/authentication/index.types.d.ts index f229cbfcc18..a082fde9736 100644 --- a/core/api/src/domain/authentication/index.types.d.ts +++ b/core/api/src/domain/authentication/index.types.d.ts @@ -65,13 +65,20 @@ type RegistrationPayload = { userId: UserId phone: PhoneNumber phoneMetadata: PhoneMetadata | undefined + referral: Referral | undefined } + +type TransientPayload = { + phoneMetadata?: Record> + referral?: string +} + type RegistrationPayloadValidator = { validate(rawBody: { identity_id?: string phone?: string schema_id?: string - transient_payload?: { phoneMetadata?: Record> } + transient_payload?: TransientPayload }): RegistrationPayload | ValidationError } @@ -83,6 +90,7 @@ interface IAuthWithPhonePasswordlessService { createIdentityWithSession(args: { phone: PhoneNumber phoneMetadata?: PhoneMetadata + referral?: Referral | undefined }): Promise updateIdentityFromDeviceAccount(args: { phone: PhoneNumber diff --git a/core/api/src/domain/authentication/registration-payload-validator.ts b/core/api/src/domain/authentication/registration-payload-validator.ts index bcb7c356c50..49e00b093ff 100644 --- a/core/api/src/domain/authentication/registration-payload-validator.ts +++ b/core/api/src/domain/authentication/registration-payload-validator.ts @@ -16,7 +16,7 @@ export const RegistrationPayloadValidator = ( identity_id?: string phone?: string schema_id?: string - transient_payload?: { phoneMetadata?: Record> } + transient_payload?: TransientPayload }): RegistrationPayload | ValidationError => { const { identity_id: userIdRaw, @@ -40,6 +40,7 @@ export const RegistrationPayloadValidator = ( if (phone instanceof Error) return phone const rawPhoneMetadata = transient_payload?.phoneMetadata + const referral = transient_payload?.referral as Referral | undefined let phoneMetadata: PhoneMetadata | undefined = undefined @@ -56,6 +57,7 @@ export const RegistrationPayloadValidator = ( userId: userIdChecked, phone, phoneMetadata, + referral, } } diff --git a/core/api/src/domain/errors.ts b/core/api/src/domain/errors.ts index ba4c4dcda8e..83c97ca1906 100644 --- a/core/api/src/domain/errors.ts +++ b/core/api/src/domain/errors.ts @@ -26,7 +26,7 @@ export class CannotConnectToDbError extends RepositoryError { export class DbConnectionClosedError extends RepositoryError { level = ErrorLevel.Critical } -export class MultipleWalletsFoundForAccountIdAndCurrency extends RepositoryError {} +export class MultipleWalletsFoundForAccountIdAndCurrencyError extends RepositoryError {} export class WalletInvoiceMissingLnInvoiceError extends RepositoryError {} export class CouldNotUnsetPhoneFromUserError extends CouldNotUpdateError {} @@ -87,6 +87,7 @@ export class InvalidPubKeyError extends ValidationError {} export class InvalidScanDepthAmount extends ValidationError {} export class SatoshiAmountRequiredError extends ValidationError {} export class InvalidUsername extends ValidationError {} +export class InvalidReferralError extends ValidationError {} export class InvalidDeviceId extends ValidationError {} export class InvalidIdentityPassword extends ValidationError {} export class InvalidIdentityUsername extends ValidationError {} diff --git a/core/api/src/domain/users/index.ts b/core/api/src/domain/users/index.ts index 1c6fb89e899..b2135d5ccc4 100644 --- a/core/api/src/domain/users/index.ts +++ b/core/api/src/domain/users/index.ts @@ -8,6 +8,7 @@ import { InvalidIdentityUsername, InvalidLanguageError, InvalidPhoneNumber, + InvalidReferralError, } from "@/domain/errors" export * from "./phone-metadata-authorizer" @@ -89,4 +90,11 @@ export const checkedToIdentityPassword = ( return password as IdentityPassword } +export const checkedToReferral = (referral: string): Referral | ValidationError => { + if (referral.length > 12) { + return new InvalidReferralError(referral) + } + return referral as Referral +} + export { Languages } diff --git a/core/api/src/domain/users/index.types.d.ts b/core/api/src/domain/users/index.types.d.ts index 1a2ec96ca52..c264d3cda58 100644 --- a/core/api/src/domain/users/index.types.d.ts +++ b/core/api/src/domain/users/index.types.d.ts @@ -1,6 +1,8 @@ type PhoneNumber = string & { readonly brand: unique symbol } type PhoneCode = string & { readonly brand: unique symbol } +type Referral = string & { readonly brand: unique symbol } + type EmailAddress = string & { readonly brand: unique symbol } type EmailCode = string & { readonly brand: unique symbol } diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 01543e3c404..25d1aeedd20 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -675,7 +675,8 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "InvalidCarrierTypeForPhoneMetadataError": case "InvalidErrorCodeForPhoneMetadataError": case "InvalidCountryCodeForPhoneMetadataError": - case "MultipleWalletsFoundForAccountIdAndCurrency": + case "MultipleWalletsFoundForAccountIdAndCurrencyError": + case "InvalidReferralError": message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ error.name }${error.message ? ": " + error.message : ""})` diff --git a/core/api/src/graphql/public/root/mutation/user-login.ts b/core/api/src/graphql/public/root/mutation/user-login.ts index 9ce119fca80..5dbdd37e68a 100644 --- a/core/api/src/graphql/public/root/mutation/user-login.ts +++ b/core/api/src/graphql/public/root/mutation/user-login.ts @@ -5,6 +5,7 @@ import Phone from "@/graphql/shared/types/scalar/phone" import AuthTokenPayload from "@/graphql/shared/types/payload/auth-token" import { mapAndParseErrorForGqlResponse } from "@/graphql/error-map" import { Authentication } from "@/app" +import Referral from "@/graphql/shared/types/scalar/referral" const UserLoginInput = GT.Input({ name: "UserLoginInput", @@ -15,6 +16,9 @@ const UserLoginInput = GT.Input({ code: { type: GT.NonNull(OneTimeAuthCode), }, + referral: { + type: Referral, + }, }), }) @@ -25,6 +29,7 @@ const UserLoginMutation = GT.Field< input: { phone: PhoneNumber | InputValidationError code: PhoneCode | InputValidationError + referral?: Referral | InputValidationError } } >({ @@ -36,7 +41,7 @@ const UserLoginMutation = GT.Field< input: { type: GT.NonNull(UserLoginInput) }, }, resolve: async (_, args, { ip }) => { - const { phone, code } = args.input + const { phone, code, referral } = args.input if (phone instanceof Error) { return { errors: [{ message: phone.message }] } @@ -46,6 +51,10 @@ const UserLoginMutation = GT.Field< return { errors: [{ message: code.message }] } } + if (referral instanceof Error) { + return { errors: [{ message: referral.message }] } + } + if (ip === undefined) { return { errors: [{ message: "ip is undefined" }] } } @@ -54,6 +63,7 @@ const UserLoginMutation = GT.Field< phone, code, ip, + referral, }) if (res instanceof Error) { diff --git a/core/api/src/graphql/public/schema.graphql b/core/api/src/graphql/public/schema.graphql index 4cc6a86bd0d..fcdbeaf8d8d 100644 --- a/core/api/src/graphql/public/schema.graphql +++ b/core/api/src/graphql/public/schema.graphql @@ -1189,6 +1189,9 @@ type RealtimePricePayload { realtimePrice: RealtimePrice } +"""Referral code provided by a community""" +scalar Referral + """ Non-fractional signed whole numeric value between -(2^53) + 1 and 2^53 - 1 """ @@ -1509,6 +1512,7 @@ type UserEmailRegistrationValidatePayload { input UserLoginInput { code: OneTimeAuthCode! phone: Phone! + referral: Referral } input UserLoginUpgradeInput { diff --git a/core/api/src/graphql/shared/types/scalar/referral.ts b/core/api/src/graphql/shared/types/scalar/referral.ts new file mode 100644 index 00000000000..fd0b72b80a8 --- /dev/null +++ b/core/api/src/graphql/shared/types/scalar/referral.ts @@ -0,0 +1,31 @@ +import { checkedToReferral } from "@/domain/users" +import { InputValidationError } from "@/graphql/error" +import { GT } from "@/graphql/index" + +const Referral = GT.Scalar({ + name: "Referral", + description: "Referral code provided by a community", + parseValue(value) { + if (typeof value !== "string") { + return new InputValidationError({ message: "Invalid type for Referral" }) + } + return validReferralValue(value) + }, + parseLiteral(ast) { + if (ast.kind === GT.Kind.STRING) { + return validReferralValue(ast.value) + } + return new InputValidationError({ message: "Invalid type for Referral" }) + }, +}) + +function validReferralValue(value: string) { + const ReferralNumberValid = checkedToReferral(value) + if (ReferralNumberValid instanceof Error) + return new InputValidationError({ + message: "Referral is not a valid", + }) + return ReferralNumberValid +} + +export default Referral diff --git a/core/api/src/services/kratos/auth-phone-no-password.ts b/core/api/src/services/kratos/auth-phone-no-password.ts index a3a536f55c4..50247cfdf0f 100644 --- a/core/api/src/services/kratos/auth-phone-no-password.ts +++ b/core/api/src/services/kratos/auth-phone-no-password.ts @@ -77,9 +77,11 @@ export const AuthWithPhonePasswordlessService = (): IAuthWithPhonePasswordlessSe const createIdentityWithSession = async ({ phone, phoneMetadata, + referral, }: { phone: PhoneNumber phoneMetadata?: PhoneMetadata + referral?: Referral }): Promise => { const traits = { phone } const method = "password" @@ -91,7 +93,7 @@ export const AuthWithPhonePasswordlessService = (): IAuthWithPhonePasswordlessSe traits, method, password, - transient_payload: { phoneMetadata }, + transient_payload: { phoneMetadata, referral }, }, }) const authToken = result.data.session_token as AuthToken diff --git a/core/api/src/services/mongoose/accounts.ts b/core/api/src/services/mongoose/accounts.ts index 27a84be6c3c..13d057378ac 100644 --- a/core/api/src/services/mongoose/accounts.ts +++ b/core/api/src/services/mongoose/accounts.ts @@ -105,6 +105,7 @@ export const AccountsRepository = (): IAccountsRepository => { kratosUserId, displayCurrency, notificationSettings, + referral, role, }: Account): Promise => { @@ -130,6 +131,7 @@ export const AccountsRepository = (): IAccountsRepository => { kratosUserId, displayCurrency, notificationSettings, + referral, role, }, diff --git a/core/api/src/services/mongoose/schema.ts b/core/api/src/services/mongoose/schema.ts index afede0eb8a9..2c224c85934 100644 --- a/core/api/src/services/mongoose/schema.ts +++ b/core/api/src/services/mongoose/schema.ts @@ -295,6 +295,8 @@ const AccountSchema = new Schema( index: true, }, + referral: String, + displayCurrency: String, // FIXME: should be an enum }, { id: false }, diff --git a/core/api/src/services/mongoose/schema.types.d.ts b/core/api/src/services/mongoose/schema.types.d.ts index 757d3e071f9..4f0fb86bc00 100644 --- a/core/api/src/services/mongoose/schema.types.d.ts +++ b/core/api/src/services/mongoose/schema.types.d.ts @@ -95,6 +95,8 @@ interface AccountRecord { displayCurrency?: string notificationSettings?: NotificationSettings + referral?: Referral + // business: title?: string coordinates?: CoordinateObjectForUser diff --git a/core/api/src/services/mongoose/wallets.ts b/core/api/src/services/mongoose/wallets.ts index 1cd9a059002..4d471e9992a 100644 --- a/core/api/src/services/mongoose/wallets.ts +++ b/core/api/src/services/mongoose/wallets.ts @@ -13,7 +13,7 @@ import { CouldNotFindWalletFromOnChainAddressesError, CouldNotListWalletsFromAccountIdError, CouldNotListWalletsFromWalletCurrencyError, - MultipleWalletsFoundForAccountIdAndCurrency, + MultipleWalletsFoundForAccountIdAndCurrencyError, } from "@/domain/errors" export const WalletsRepository = (): IWalletsRepository => { @@ -99,7 +99,7 @@ export const WalletsRepository = (): IWalletsRepository => { return new CouldNotFindWalletFromAccountIdAndCurrencyError(WalletCurrency.Btc) } if (btcWallets.length > 1) { - return new MultipleWalletsFoundForAccountIdAndCurrency(WalletCurrency.Btc) + return new MultipleWalletsFoundForAccountIdAndCurrencyError(WalletCurrency.Btc) } const btcWallet = btcWallets[0] @@ -108,7 +108,7 @@ export const WalletsRepository = (): IWalletsRepository => { return new CouldNotFindWalletFromAccountIdAndCurrencyError(WalletCurrency.Usd) } if (usdWallets.length > 1) { - return new MultipleWalletsFoundForAccountIdAndCurrency(WalletCurrency.Usd) + return new MultipleWalletsFoundForAccountIdAndCurrencyError(WalletCurrency.Usd) } const usdWallet = usdWallets[0] diff --git a/core/api/test/helpers/user.ts b/core/api/test/helpers/user.ts index 34836f73adc..736a5f23ef6 100644 --- a/core/api/test/helpers/user.ts +++ b/core/api/test/helpers/user.ts @@ -135,6 +135,7 @@ export const createUserAndWalletFromPhone = async ( account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) if (account instanceof Error) throw account @@ -198,6 +199,7 @@ export const createAccount = async ({ initialWallets, initialLevel: AccountLevel.One, }, + referral: undefined, }) if (account instanceof Error) throw account @@ -260,6 +262,7 @@ export const createUserAndWallet = async ( account = await createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) if (account instanceof Error) throw account diff --git a/core/api/test/integration/services/wallets-repository.spec.ts b/core/api/test/integration/services/wallets-repository.spec.ts index e3ab0873239..072e81651e0 100644 --- a/core/api/test/integration/services/wallets-repository.spec.ts +++ b/core/api/test/integration/services/wallets-repository.spec.ts @@ -2,7 +2,7 @@ import { randomUUID } from "crypto" import { CouldNotFindWalletFromAccountIdAndCurrencyError, - MultipleWalletsFoundForAccountIdAndCurrency, + MultipleWalletsFoundForAccountIdAndCurrencyError, RepositoryError, } from "@/domain/errors" import { WalletCurrency } from "@/domain/shared" @@ -68,7 +68,9 @@ describe("WalletsRepository", () => { await newWallet(WalletCurrency.Usd) const accountWallets = await wallets.findAccountWalletsByAccountId(accountId) - expect(accountWallets).toBeInstanceOf(MultipleWalletsFoundForAccountIdAndCurrency) + expect(accountWallets).toBeInstanceOf( + MultipleWalletsFoundForAccountIdAndCurrencyError, + ) expect((accountWallets as RepositoryError).message).toBe(WalletCurrency.Btc) }) @@ -78,7 +80,9 @@ describe("WalletsRepository", () => { await newWallet(WalletCurrency.Usd) const accountWallets = await wallets.findAccountWalletsByAccountId(accountId) - expect(accountWallets).toBeInstanceOf(MultipleWalletsFoundForAccountIdAndCurrency) + expect(accountWallets).toBeInstanceOf( + MultipleWalletsFoundForAccountIdAndCurrencyError, + ) expect((accountWallets as RepositoryError).message).toBe(WalletCurrency.Usd) }) }) diff --git a/core/api/test/legacy-integration/app/accounts/multiple-wallet-account.spec.ts b/core/api/test/legacy-integration/app/accounts/multiple-wallet-account.spec.ts index 2aeca519e0d..99686b440ad 100644 --- a/core/api/test/legacy-integration/app/accounts/multiple-wallet-account.spec.ts +++ b/core/api/test/legacy-integration/app/accounts/multiple-wallet-account.spec.ts @@ -22,6 +22,7 @@ it("change default walletId of account", async () => { const account = await Accounts.createAccountWithPhoneIdentifier({ newAccountInfo: { phone, kratosUserId }, config: getDefaultAccountsConfig(), + referral: undefined, }) if (account instanceof Error) throw account diff --git a/core/api/test/unit/domain/authentication/registration-payload-validator.spec.ts b/core/api/test/unit/domain/authentication/registration-payload-validator.spec.ts index 310bffce3f1..8453cc52bc6 100644 --- a/core/api/test/unit/domain/authentication/registration-payload-validator.spec.ts +++ b/core/api/test/unit/domain/authentication/registration-payload-validator.spec.ts @@ -19,6 +19,7 @@ describe("RegistrationPayloadValidator", () => { userId: rawUserId as UserId, phone: rawPhone, phoneMetadata: undefined, + referral: undefined, } const validated = validator.validate({