// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { z } from 'zod'; export const ONE_TIME_DONATION_CONFIG_ID = '1'; export const BOOST_ID = 'BOOST'; /** * For one-time donations, there are 2 possible flows: * * Stripe: * INTENT -> INTENT_METHOD -> (INTENT_REDIRECT) -> PAYMENT_CONFIRMED -> RECEIPT -> DONE * - INTENT_REDIRECT only occurs when Stripe requires additional validation (e.g. 3ds). * * PayPal: * PAYPAL_INTENT -> PAYPAL_APPROVED -> PAYMENT_CONFIRMED -> RECEIPT -> DONE */ export const donationStateSchema = z.enum([ 'INTENT', 'INTENT_METHOD', 'INTENT_CONFIRMED', // Deprecated 'INTENT_REDIRECT', 'PAYPAL_INTENT', 'PAYPAL_APPROVED', 'PAYMENT_CONFIRMED', 'RECEIPT', 'DONE', ]); export type DonationStateType = z.infer; export enum DonationProcessor { Paypal = 'PAYPAL', Stripe = 'STRIPE', } export enum PaymentMethod { Card = 'CARD', Paypal = 'PAYPAL', } export const donationProcessorSchema = z.nativeEnum(DonationProcessor); export const donationErrorTypeSchema = z.enum([ // Used if the user is redirected back from validation, but continuing forward fails 'Failed3dsValidation', // Any other error 'GeneralError', // Any 4xx error when adding payment method or confirming intent 'PaymentDeclined', // When the user approves PayPal payment but they canceled it in-app, so we lost the // Paypal state. The user will not be charged, because they are only charged when we // confirm with the server. 'PaypalError', // When the user cancels PayPal payment. 'PaypalCanceled', // When it's been too long since the last step of the donation, and card wasn't charged 'TimedOut', // When donation succeeds but badge application fails 'BadgeApplicationFailed', ]); export type DonationErrorType = z.infer; const coreDataSchema = z.object({ // Guid used to prevent duplicates at stripe and in our db id: z.string(), // Currency 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. timestamp: z.number(), }); export type CoreData = z.infer; // Payment type: CARD export type CardDetail = { // Two digits expirationMonth: string; // Four digts expirationYear: string; // String with no separators, just 16 digits number: string; // String cvc: string; }; const stripeDataSchema = z.object({ // Received after creation of intent clientSecret: z.string(), // Parsed out of clientSecret - it's everything up to the '_secret_' // https://docs.stripe.com/api/payment_intents/object paymentIntentId: z.string(), // Used for any validation that takes the user somewhere else returnToken: z.string(), }); export type StripeData = z.infer; // We need these for durability. if we keep these around throughout the process, retries // later in the process won't give us weird errors. // Generated by libsignal. const receiptContextSchema = z.object({ receiptCredentialRequestContextBase64: z.string(), receiptCredentialRequestBase64: z.string(), }); export type ReceiptContext = z.infer; export const donationReceiptSchema = z.object({ ...coreDataSchema.shape, }); export type DonationReceipt = z.infer; export const donationWorkflowSchema = z.discriminatedUnion('type', [ z.object({ // Track that user has chosen currency and amount, and we've successfully fetched an // intent. There is no need to persist this, because we'd need to update // currency/amount on the intent if we want to continue to use it. type: z.literal(donationStateSchema.Enum.INTENT), ...coreDataSchema.shape, ...stripeDataSchema.shape, }), z.object({ // Once we are here, we can proceed without further user input. The user has entered // payment details and pressed the button to make the payment, and we have sent that // to stripe, which has saved that data behind a paymentMethodId. The only thing // that might require further user interaction: 3ds validation - see INTENT_REDIRECT. type: z.literal(donationStateSchema.Enum.INTENT_METHOD), // Stripe persists the user's payment information for us, behind this id paymentMethodId: z.string(), ...coreDataSchema.shape, ...stripeDataSchema.shape, }), z.object({ // Deprecated. We no longer enter this state -- PAYMENT_CONFIRMED has replaced it. // By this point, Stripe is attempting to charge the user's provided payment method. // However it will take some time (usually seconds, sometimes minutes or 1 day) to // finalize the transaction. We will only know when we successfully get a receipt // credential from the chat server. type: z.literal(donationStateSchema.Enum.INTENT_CONFIRMED), ...coreDataSchema.shape, ...stripeDataSchema.shape, ...receiptContextSchema.shape, }), z.object({ // This state is shared by Stripe and PayPal. // Stripe: By this point, Stripe is attempting to charge the user's payment method. // However it will take some time (usually seconds, sometimes minutes or 1 day) to // finalize the transaction. We will only know when we successfully get a receipt // credential from the chat server. // PayPal: Payment should finalize immediately. The subsequent call to get a receipt // should always succeed. type: z.literal(donationStateSchema.Enum.PAYMENT_CONFIRMED), processor: donationProcessorSchema, paymentIntentId: z.string(), ...coreDataSchema.shape, ...receiptContextSchema.shape, }), z.object({ // An alternate state to PAYMENT_CONFIRMED. A response from Stripe indicated // the user's card requires 3ds authentication, so we need to redirect to their // bank, which will complete verification, then redirect back to us. We hand that // service a token to connect it back to this process. If the user never comes back, // we need to offer the redirect again. type: z.literal(donationStateSchema.Enum.INTENT_REDIRECT), // Where user should be sent; in this state we are waiting for them to come back redirectTarget: z.string(), ...coreDataSchema.shape, ...stripeDataSchema.shape, ...receiptContextSchema.shape, }), z.object({ // User has selected currency and amount, and we've initiated a PayPal payment // via the chat server. To complete the payment, the user needs to visit the // PayPal website. Upon approval, the website redirects to the app URI to // continue the donation process to get a receipt and badge from the chat server. // To cancel, a user can either cancel on the Paypal website, or cancel from within // the app (which just clears the active transaction locally). type: z.literal(donationStateSchema.Enum.PAYPAL_INTENT), paypalPaymentId: z.string(), // The user needs to visit this URL to complete payment on PayPal. approvalUrl: z.string(), // When the user returns to the app, we check the returnToken to confirm it matches // the active workflow. returnToken: z.string(), ...coreDataSchema.shape, }), z.object({ // After PayPal approval, the user is redirected back to us with a payerId and // paymentToken. We save them immediately, then // confirm the payment on the chat server. type: z.literal(donationStateSchema.Enum.PAYPAL_APPROVED), paypalPaymentId: z.string(), paypalPayerId: z.string(), paypalPaymentToken: z.string(), ...coreDataSchema.shape, }), z.object({ // We now have everything we need to redeem. We know the payment has gone through // successfully; we just need to redeem it on the server anonymously. type: z.literal(donationStateSchema.Enum.RECEIPT), // The result of mixing the receiptCredentialResponse from the API from our // previously-generated receiptCredentialRequestContext receiptCredentialBase64: z.string(), ...coreDataSchema.shape, }), z.object({ // After everything is done, we should notify the user the donation succeeded. // After we show a notification, or if the user initiates a new donation, // then this workflow can be deleted. type: z.literal(donationStateSchema.Enum.DONE), id: coreDataSchema.shape.id, timestamp: coreDataSchema.shape.timestamp, }), ]); export type DonationWorkflow = z.infer; export const humanDonationAmountSchema = z .number() .nonnegative() .brand('humanAmount'); export type HumanDonationAmount = z.infer; // Always in currency minor units e.g. 1000 for 10 USD, 10 for 10 JPY // https://docs.stripe.com/currencies#minor-units export const stripeDonationAmountSchema = z .number() .nonnegative() .brand('stripeAmount'); export type StripeDonationAmount = z.infer; export const subscriptionConfigurationCurrencyZod = z.object({ minimum: humanDonationAmountSchema, oneTime: z.record(z.string(), humanDonationAmountSchema.array()), supportedPaymentMethods: z.array(z.string()), }); export const oneTimeDonationAmountsZod = z.record( z.string(), subscriptionConfigurationCurrencyZod ); export type OneTimeDonationHumanAmounts = z.infer< typeof oneTimeDonationAmountsZod >;