Files
Desktop/ts/types/Donations.std.ts
ayumi-signal 0c7fcfdaef Donation data workflows for PayPal
Co-authored-by: Scott Nonnenberg <scott@signal.org>
2026-01-27 16:29:27 -08:00

264 lines
8.8 KiB
TypeScript

// 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<typeof donationStateSchema>;
export const donationProcessorSchema = z.enum(['PAYPAL', 'STRIPE']);
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 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<typeof donationErrorTypeSchema>;
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<typeof coreDataSchema>;
// 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<typeof stripeDataSchema>;
// 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<typeof receiptContextSchema>;
export const donationReceiptSchema = z.object({
...coreDataSchema.shape,
});
export type DonationReceipt = z.infer<typeof donationReceiptSchema>;
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<typeof donationWorkflowSchema>;
export const humanDonationAmountSchema = z
.number()
.nonnegative()
.brand('humanAmount');
export type HumanDonationAmount = z.infer<typeof humanDonationAmountSchema>;
// 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<typeof stripeDonationAmountSchema>;
export const subscriptionConfigurationCurrencyZod = z.object({
minimum: humanDonationAmountSchema,
oneTime: z.record(z.string(), humanDonationAmountSchema.array()),
});
export const oneTimeDonationAmountsZod = z.record(
z.string(),
subscriptionConfigurationCurrencyZod
);
export type OneTimeDonationHumanAmounts = z.infer<
typeof oneTimeDonationAmountsZod
>;