diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 01de697ce8..688d1e03bc 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -3,6 +3,9 @@ import type { Database } from '@signalapp/sqlcipher'; import type { ReadonlyDeep } from 'type-fest'; + +import { strictAssert } from '../util/assert'; + import type { ConversationAttributesType, MessageAttributesType, @@ -49,7 +52,7 @@ import type { SyncTaskType } from '../util/syncTasks'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { GifType } from '../components/fun/panels/FunPanelGifs'; import type { NotificationProfileType } from '../types/NotificationProfile'; -import { strictAssert } from '../util/assert'; +import type { DonationReceipt } from '../types/Donations'; export type ReadableDB = Database & { __readable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never }; @@ -876,6 +879,9 @@ type ReadableInterface = { getAllNotificationProfiles(): Array; getNotificationProfileById(id: string): NotificationProfileType | undefined; + getAllDonationReceipts(): Array; + getDonationReceiptById(id: string): DonationReceipt | undefined; + getMessagesNeedingUpgrade: ( limit: number, options: { maxVersion: number } @@ -1185,6 +1191,10 @@ type WritableInterface = { createNotificationProfile(profile: NotificationProfileType): void; updateNotificationProfile(profile: NotificationProfileType): void; + _deleteAllDonationReceipts(): void; + deleteDonationReceiptById(id: string): void; + createDonationReceipt(profile: DonationReceipt): void; + removeAll: () => void; removeAllConfiguration: () => void; eraseStorageServiceState: () => void; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 078201ec26..4061a0cb72 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -218,6 +218,13 @@ import { updateCallLinkState, updateDefunctCallLink, } from './server/callLinks'; +import { + _deleteAllDonationReceipts, + createDonationReceipt, + deleteDonationReceiptById, + getAllDonationReceipts, + getDonationReceiptById, +} from './server/donationReceipts'; import { deleteAllEndorsementsForGroup, getGroupSendCombinedEndorsementExpiration, @@ -391,6 +398,9 @@ export const DataReader: ServerReadableInterface = { getAllNotificationProfiles, getNotificationProfileById, + getAllDonationReceipts, + getDonationReceiptById, + callLinkExists, defunctCallLinkExists, getAllCallLinks, @@ -624,6 +634,10 @@ export const DataWriter: ServerWritableInterface = { markNotificationProfileDeleted, updateNotificationProfile, + _deleteAllDonationReceipts, + deleteDonationReceiptById, + createDonationReceipt, + removeAll, removeAllConfiguration, eraseStorageServiceState, @@ -7495,6 +7509,7 @@ function removeAll(db: WritableDB): void { DELETE FROM callsHistory; DELETE FROM conversations; DELETE FROM defunctCallLinks; + DELETE FROM donationReceipts; DELETE FROM emojis; DELETE FROM groupCallRingCancellations; DELETE FROM groupSendCombinedEndorsement; diff --git a/ts/sql/migrations/1380-donation-receipts.ts b/ts/sql/migrations/1380-donation-receipts.ts new file mode 100644 index 0000000000..06af5722b7 --- /dev/null +++ b/ts/sql/migrations/1380-donation-receipts.ts @@ -0,0 +1,35 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { LoggerType } from '../../types/Logging'; +import type { WritableDB } from '../Interface'; + +export const version = 1380; + +export function updateToSchemaVersion1380( + currentVersion: number, + db: WritableDB, + logger: LoggerType +): void { + if (currentVersion >= 1380) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE donationReceipts( + id TEXT NOT NULL PRIMARY KEY, + currencyType TEXT NOT NULL, + paymentAmount INTEGER NOT NULL, + paymentDetailJson TEXT NOT NULL, + paymentType TEXT NOT NULL, + timestamp INTEGER NOT NULL + ) STRICT; + + CREATE INDEX donationReceipts_byTimestamp on donationReceipts(timestamp); + `); + db.pragma('user_version = 1380'); + })(); + + logger.info('updateToSchemaVersion1380: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 21375dca58..486d23e260 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -112,10 +112,11 @@ import { updateToSchemaVersion1330 } from './1330-sync-tasks-type-index'; import { updateToSchemaVersion1340 } from './1340-recent-gifs'; import { updateToSchemaVersion1350 } from './1350-notification-profiles'; import { updateToSchemaVersion1360 } from './1360-attachments'; +import { updateToSchemaVersion1370 } from './1370-message-attachment-indexes'; import { - updateToSchemaVersion1370, + updateToSchemaVersion1380, version as MAX_VERSION, -} from './1370-message-attachment-indexes'; +} from './1380-donation-receipts'; import { DataWriter } from '../Server'; @@ -2106,6 +2107,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1350, updateToSchemaVersion1360, updateToSchemaVersion1370, + updateToSchemaVersion1380, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/donationReceipts.ts b/ts/sql/server/donationReceipts.ts new file mode 100644 index 0000000000..c26876e1c1 --- /dev/null +++ b/ts/sql/server/donationReceipts.ts @@ -0,0 +1,108 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { omit } from 'lodash'; + +import * as Errors from '../../types/errors'; +import { safeParseLoose } from '../../util/schemas'; +import { sql } from '../util'; +import { sqlLogger } from '../sqlLogger'; +import { donationReceiptSchema } from '../../types/Donations'; + +import type { DonationReceipt } from '../../types/Donations'; +import type { ReadableDB, WritableDB } from '../Interface'; + +type DonationReceiptForDatabase = Readonly< + { + paymentDetailJson: string; + paymentType: string; + } & Omit +>; + +function hydrateDonationReceipt( + receipt: DonationReceiptForDatabase +): DonationReceipt { + const readyForParse = { + ...omit(receipt, ['paymentDetailJson']), + paymentDetail: JSON.parse(receipt.paymentDetailJson), + }; + + const result = safeParseLoose(donationReceiptSchema, readyForParse); + if (result.success) { + return result.data; + } + + sqlLogger.error( + `hydrateDonationReceipt: Parse failed for payment type ${readyForParse.paymentType}:`, + Errors.toLogFormat(result.error) + ); + const toFix = readyForParse as unknown as DonationReceipt; + toFix.paymentDetail = null; + return toFix; +} +export function freezeDonationReceipt( + receipt: DonationReceipt +): DonationReceiptForDatabase { + return { + ...omit(receipt, ['paymentDetail']), + paymentDetailJson: JSON.stringify(receipt.paymentDetail), + }; +} + +export function getAllDonationReceipts(db: ReadableDB): Array { + const donationReceipts = db + .prepare('SELECT * FROM donationReceipts ORDER BY timestamp DESC;') + .all(); + + return donationReceipts.map(hydrateDonationReceipt); +} +export function getDonationReceiptById( + db: ReadableDB, + id: string +): DonationReceipt | undefined { + const [query, parameters] = + sql`SELECT * FROM donationReceipts WHERE id = ${id}`; + const fromDatabase = db + .prepare(query) + .get(parameters); + + if (fromDatabase) { + return hydrateDonationReceipt(fromDatabase); + } + + return undefined; +} +export function _deleteAllDonationReceipts(db: WritableDB): void { + db.prepare('DELETE FROM donationReceipts;').run(); +} +export function deleteDonationReceiptById(db: WritableDB, id: string): void { + const [query, parameters] = + sql`DELETE FROM donationReceipts WHERE id = ${id};`; + db.prepare(query).run(parameters); +} +export function createDonationReceipt( + db: WritableDB, + receipt: DonationReceipt +): void { + const forDatabase = freezeDonationReceipt(receipt); + + db.prepare( + ` + INSERT INTO donationReceipts( + id, + currencyType, + paymentAmount, + paymentDetailJson, + paymentType, + timestamp + ) VALUES ( + $id, + $currencyType, + $paymentAmount, + $paymentDetailJson, + $paymentType, + $timestamp + ); + ` + ).run(forDatabase); +} diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 8e1e6db372..b247ac667d 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -11,6 +11,7 @@ import { actions as calling } from './ducks/calling'; import { actions as composer } from './ducks/composer'; import { actions as conversations } from './ducks/conversations'; import { actions as crashReports } from './ducks/crashReports'; +import { actions as donations } from './ducks/donations'; import { actions as emojis } from './ducks/emojis'; import { actions as expiration } from './ducks/expiration'; import { actions as gifs } from './ducks/gifs'; @@ -46,6 +47,7 @@ export const actionCreators: ReduxActions = { composer, conversations, crashReports, + donations, emojis, expiration, gifs, diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts new file mode 100644 index 0000000000..4c3ad6d848 --- /dev/null +++ b/ts/state/ducks/donations.ts @@ -0,0 +1,65 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ReadonlyDeep } from 'type-fest'; + +import { useBoundActions } from '../../hooks/useBoundActions'; + +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import type { DonationReceipt } from '../../types/Donations'; + +// State + +export type DonationsStateType = ReadonlyDeep<{ + receipts: Array; +}>; + +// Actions + +export const ADD_RECEIPT = 'donations/ADD_RECEIPT'; + +export type AddReceiptAction = ReadonlyDeep<{ + type: typeof ADD_RECEIPT; + payload: { receipt: DonationReceipt }; +}>; + +export type DonationsActionType = ReadonlyDeep; + +// Action Creators + +export function addReceipt(receipt: DonationReceipt): AddReceiptAction { + return { + type: ADD_RECEIPT, + payload: { receipt }, + }; +} + +export const actions = { + addReceipt, +}; + +export const useDonationsActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +// Reducer + +export function getEmptyState(): DonationsStateType { + return { + receipts: [], + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): DonationsStateType { + if (action.type === ADD_RECEIPT) { + return { + ...state, + receipts: [...state.receipts, action.payload.receipt], + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index e37d18dd10..82f1ebff08 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -11,6 +11,7 @@ import { getEmptyState as callingEmptyState } from './ducks/calling'; import { getEmptyState as composerEmptyState } from './ducks/composer'; import { getEmptyState as conversationsEmptyState } from './ducks/conversations'; import { getEmptyState as crashReportsEmptyState } from './ducks/crashReports'; +import { getEmptyState as donationsEmptyState } from './ducks/donations'; import { getEmptyState as emojiEmptyState } from './ducks/emojis'; import { getEmptyState as expirationEmptyState } from './ducks/expiration'; import { getEmptyState as gifsEmptyState } from './ducks/gifs'; @@ -140,6 +141,7 @@ function getEmptyState(): StateType { composer: composerEmptyState(), conversations: generateConversationsState(), crashReports: crashReportsEmptyState(), + donations: donationsEmptyState(), emojis: emojiEmptyState(), gifs: gifsEmptyState(), expiration: expirationEmptyState(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index f1616cd912..d0de66bc78 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -65,6 +65,7 @@ export function initializeRedux(data: ReduxInitData): void { store.dispatch ), inbox: bindActionCreators(actionCreators.inbox, store.dispatch), + donations: bindActionCreators(actionCreators.donations, store.dispatch), emojis: bindActionCreators(actionCreators.emojis, store.dispatch), expiration: bindActionCreators(actionCreators.expiration, store.dispatch), gifs: bindActionCreators(actionCreators.gifs, store.dispatch), diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index bced6947b2..8598804523 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -13,6 +13,7 @@ import { reducer as callHistory } from './ducks/callHistory'; import { reducer as composer } from './ducks/composer'; import { reducer as conversations } from './ducks/conversations'; import { reducer as crashReports } from './ducks/crashReports'; +import { reducer as donations } from './ducks/donations'; import { reducer as emojis } from './ducks/emojis'; import { reducer as expiration } from './ducks/expiration'; import { reducer as gifs } from './ducks/gifs'; @@ -48,6 +49,7 @@ export const reducer = combineReducers({ composer, conversations, crashReports, + donations, emojis, expiration, gifs, diff --git a/ts/state/types.ts b/ts/state/types.ts index e8085b3ed5..8229834fac 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -11,6 +11,7 @@ import type { actions as calling } from './ducks/calling'; import type { actions as composer } from './ducks/composer'; import type { actions as conversations } from './ducks/conversations'; import type { actions as crashReports } from './ducks/crashReports'; +import type { actions as donations } from './ducks/donations'; import type { actions as emojis } from './ducks/emojis'; import type { actions as expiration } from './ducks/expiration'; import type { actions as gifs } from './ducks/gifs'; @@ -45,6 +46,7 @@ export type ReduxActions = { composer: typeof composer; conversations: typeof conversations; crashReports: typeof crashReports; + donations: typeof donations; emojis: typeof emojis; expiration: typeof expiration; gifs: typeof gifs; diff --git a/ts/test-electron/sql/donationReceipts_test.ts b/ts/test-electron/sql/donationReceipts_test.ts new file mode 100644 index 0000000000..d9e49c3e04 --- /dev/null +++ b/ts/test-electron/sql/donationReceipts_test.ts @@ -0,0 +1,95 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v1 as getGuid } from 'uuid'; +import { omit } from 'lodash'; + +import { DataReader, DataWriter } from '../../sql/Client'; + +import type { DonationReceipt } from '../../types/Donations'; + +const { getAllDonationReceipts, getDonationReceiptById } = DataReader; +const { + _deleteAllDonationReceipts, + createDonationReceipt, + deleteDonationReceiptById, +} = DataWriter; + +describe('sql/DonationReceipts', () => { + beforeEach(async () => { + await _deleteAllDonationReceipts(); + }); + after(async () => { + await _deleteAllDonationReceipts(); + }); + + it('should roundtrip', async () => { + const now = Date.now(); + const receipt1: DonationReceipt = { + id: getGuid(), + currencyType: 'USD', + paymentAmount: 500, // $5.00 + paymentType: 'CARD', + paymentDetail: { + lastFourDigits: '1111', + }, + timestamp: now, + }; + const receipt2: DonationReceipt = { + id: getGuid(), + currencyType: 'USD', + paymentAmount: 1000, // $10.00 + paymentType: 'CARD', + paymentDetail: { + lastFourDigits: '1111', + }, + timestamp: now + 10, + }; + + await createDonationReceipt(receipt1); + const receipt = await getAllDonationReceipts(); + assert.lengthOf(receipt, 1); + assert.deepEqual(receipt[0], receipt1); + + await createDonationReceipt(receipt2); + const receipts = await getAllDonationReceipts(); + assert.lengthOf(receipts, 2); + assert.deepEqual(receipts[0], receipt2); + assert.deepEqual(receipts[1], receipt1); + + await deleteDonationReceiptById(receipt1.id); + const backToreceipt = await getAllDonationReceipts(); + assert.lengthOf(backToreceipt, 1); + assert.deepEqual(backToreceipt[0], receipt2); + + const fetchedMissing = await getDonationReceiptById(receipt1.id); + assert.isUndefined(fetchedMissing); + + const fetched = await getDonationReceiptById(receipt2.id); + assert.deepEqual(fetched, receipt2); + }); + + it('clears payment detail if the zod parse fails', async () => { + const now = Date.now(); + const receipt1: DonationReceipt = { + id: getGuid(), + currencyType: 'USD', + paymentAmount: 500, // $5.00 + paymentType: 'CARD', + paymentDetail: { + // @ts-expect-error We are intentionally breaking things here + lastFourDigits: 1111, + }, + timestamp: now, + }; + + await createDonationReceipt(receipt1); + const receipt = await getAllDonationReceipts(); + assert.lengthOf(receipt, 1); + assert.deepEqual(receipt[0], { + ...omit(receipt1, 'paymentDetail'), + paymentDetail: null, + }); + }); +}); diff --git a/ts/test-node/sql/migration_1360_test.ts b/ts/test-node/sql/migration_1360_test.ts index 798f938954..2b900307e2 100644 --- a/ts/test-node/sql/migration_1360_test.ts +++ b/ts/test-node/sql/migration_1360_test.ts @@ -6,7 +6,6 @@ import { assert } from 'chai'; import { sql } from '../../sql/util'; import { createDB, explain, updateToVersion } from './helpers'; import type { WritableDB } from '../../sql/Interface'; -import { DataWriter } from '../../sql/Server'; describe('SQL/updateToSchemaVersion1360', () => { let db: WritableDB; @@ -14,7 +13,6 @@ describe('SQL/updateToSchemaVersion1360', () => { beforeEach(async () => { db = createDB(); updateToVersion(db, 1360); - await DataWriter.removeAll(db); }); afterEach(() => { diff --git a/ts/test-node/sql/migration_1380_test.ts b/ts/test-node/sql/migration_1380_test.ts new file mode 100644 index 0000000000..15078357f2 --- /dev/null +++ b/ts/test-node/sql/migration_1380_test.ts @@ -0,0 +1,71 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v1 as getGuid } from 'uuid'; + +import { sql } from '../../sql/util'; +import { + updateToVersion, + createDB, + explain, + insertData, + getTableData, +} from './helpers'; + +import type { WritableDB } from '../../sql/Interface'; + +describe('SQL/updateToSchemaVersion1380', () => { + let db: WritableDB; + beforeEach(() => { + db = createDB(); + updateToVersion(db, 1380); + }); + + afterEach(() => { + db.close(); + }); + + it('creates new donationReceipts table', () => { + const [query] = sql`SELECT * FROM donationReceipts`; + db.prepare(query).run(); + }); + + it('throws if same id is used for an insert', () => { + // Note: this kinda looks like a receipt, but the json field is weird because + // insertData and getTableData both have special handling for JSON fields. + const receipt = { + id: getGuid(), + currencyType: 'USD', + paymentAmount: 500, // $5.00 + paymentType: 'CARD', + paymentDetailJson: { + lastFourDigits: '1111', + }, + timestamp: Date.now(), + }; + + insertData(db, 'donationReceipts', [receipt]); + + assert.deepStrictEqual(getTableData(db, 'donationReceipts'), [receipt]); + + assert.throws( + () => insertData(db, 'donationReceipts', [receipt]), + /UNIQUE constraint/ + ); + }); + + it('creates an index to make order by timestamp efficient', () => { + const template = sql` + SELECT * FROM donationReceipts + ORDER BY timestamp DESC + LIMIT 5 + `; + + const details = explain(db, template); + assert.include(details, 'USING INDEX donationReceipts_byTimestamp'); + assert.notInclude(details, 'TEMP B-TREE'); + // TODO: are we actually okay with a SCAN? + // assert.notInclude(details, 'SCAN'); + }); +}); diff --git a/ts/types/Donations.ts b/ts/types/Donations.ts new file mode 100644 index 0000000000..a9d4c4a1ad --- /dev/null +++ b/ts/types/Donations.ts @@ -0,0 +1,59 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { z } from 'zod'; + +const donationStateSchema = z.enum([ + 'INTENT', + 'INTENT_METHOD', + 'INTENT_CONFIRMED', + 'INTENT_REDIRECT', + 'RECEIPT', + 'RECEIPT_REDEEMED', +]); +export type DonationState = z.infer; + +const paymentTypeSchema = z.enum(['CARD', 'PAYPAL']); +export type PaymentType = z.infer; + +const coreDataSchema = z.object({ + // guid used to prevent duplicates at stripe and in our db. + // we'll hash it and provide it to stripe as the idempotencyKey: https://docs.stripe.com/error-low-level#idempotency + id: z.string(), + + // the code, like USD + currencyType: z.string(), + + // cents as whole numbers, so multiply by 100 + paymentAmount: z.number(), + + // The last time we transitioned into a new state. So the timestamp shown to the user + // will be when we redeem the receipt, not when they click the button. + timestamp: z.number(), +}); +export type CoreData = z.infer; + +// When we add more payment types, this will become a discriminatedUnion like this: +// const paymentDetailSchema = z.discriminatedUnion('paymentType', [ +const paymentDetailSchema = z.object({ + paymentType: z.literal(paymentTypeSchema.Enum.CARD), + + // Note: we really don't want this to be null, but sometimes it won't parse, and in + // that case we still move forward and display the receipt best we can. + paymentDetail: z + .object({ + lastFourDigits: z.string(), + }) + .nullable(), +}); +export type PaymentDetails = z.infer; + +export const donationReceiptSchema = z.intersection( + z.object({ + ...coreDataSchema.shape, + }), + // This type will demand the z.intersection when it is a discriminatedUnion. When + // it is a discriminatedUnion, we can't use the ...schema.shape approach + paymentDetailSchema +); +export type DonationReceipt = z.infer;