grammyjs / grammY

The Telegram Bot Framework.
https://grammy.dev
MIT License
2.04k stars 106 forks source link

Bot sends old, incorrect data on successful purchase. #607

Closed strbit closed 1 month ago

strbit commented 1 month ago

I'm not really sure if it's an issue with grammY or with my code but i'm currently adding a payment system to my bot and i've run into an issue where (I don't really know how to put it into words) data would be "overwritten" with the old data. For example:

bot

In the screenshot you can see how I have first selected the "100 Credits" option (and have not bought it), then I selected the "50 Credits" option (and have bought it). After the purchase, the bot has responded with the UUID of the "100 Credits" option when it should've been the UUID of the "50 Credits" option (504b2ac...).

Here's the code that handles these purchases:

export async function sendPaymentInvoice(
    ctx: Context,
    composer: Composer<Context>,
    currency: string,
    product_id: AvailableProducts,
    payment_provider: AvailableProviders,
    additional_opts?: Omit<
        Other<'sendInvoice', 'chat_id' | 'title' | 'description' | 'payload' | 'currency' | 'prices'>,
        'provider_token'
    >,
    onSuccessfulPurchase?: (ctx: Context, purchase: Purchase) => void,
    onFailedPurchase?: (ctx: Context, error: Error) => void,
): Promise<void> {
    const product = getProductInfo(product_id); // Returns a JSON product.
    const random = randomUUID(); // A random UUID (just for debugging).

    if (!product) {
        if (onFailedPurchase) return onFailedPurchase(ctx, Error('This product does not exist.'));
        log.error({
            msg: `Specified product ${product_id} was not found.`,
        });
        return;
    }

    /**
     * Sends the user an invoice using the provided configurations, this
     * does not actually handle any payment data itself.
     */
    await ctx
        .replyWithInvoice(
            ctx.t('payments.invoiceTitle', {
                amount: product.reward.toString(),
                price: formatAmount(product.amount),
            }),
            ctx.t('payments.invoiceDescription', {
                amount: product.reward.toString(),
            }),
            product_id,
            currency,
            [
                {
                    label: ctx.t('payments.productLabel', {
                        amount: product.reward,
                    }),
                    amount: product.amount,
                },
            ],
            {
                ...additional_opts,
                provider_token: getProviderToken(payment_provider),
            },
        )
        .catch((err) => {
            log.error({
                msg: `Failed to send payment invoice to ${ctx.from?.id}.`,
                stack: err,
            });
            if (onFailedPurchase) return onFailedPurchase(ctx, err);
        });

    /**
     * Answers the checkout query which is required to continue with the payment.
     * @see https://core.telegram.org/bots/api#answerprecheckoutquery
     */
    composer.on('pre_checkout_query', async (ctx) => ctx.answerPreCheckoutQuery(true));

    ctx.reply(`Pre-success: ${JSON.stringify(product)}`); // This would correctly respond with the selected credits option.
    ctx.reply(`Pre-success: ${random}`);

    /**
     * Responds to a successful payment and updates the user's balance.
     */
    composer.on(':successful_payment', async (ctx: Context, _next) => {
        const payment = getUpdateInfo(ctx).message?.successful_payment!;
        const purchase: Purchase = {
            amount: payment.total_amount, // how much?
            reward: product.reward, // how much in return?
            successful: true, // how did it go?
            purchaseId: payment.telegram_payment_charge_id, // by who?
            date: new Date(), // when?
        };

        ctx.reply(`Post-success: ${JSON.stringify(product)}`); // This would respond with the option picked PRIOR to this one.
        ctx.reply(`Post-success: ${random}`);

        // Update the user balance.
        updateUserBalance(ctx, purchase.reward, purchase, true)
            .then(() => {
                ctx.reply(
                    ctx.t('payments.onPurchaseCompleted', {
                        amount: purchase.reward,
                        price: formatAmount(purchase.amount),
                        balance: getCachedBalance(ctx)
                    }),
                );

                // If a successful callback was provided, run it.
                if (onSuccessfulPurchase) return onSuccessfulPurchase(ctx, purchase);
            })
            .catch((err) => {
                ctx.reply(ctx.t('payments.onPurchaseFailed'));

                // If a failed callback was provided, run it.
                if (onFailedPurchase) return onFailedPurchase(ctx, err);
            });
    });
}

All products are simply stored in an array:

export const products: PurchaseOptionProduct[] = [
    {
        id: 'credits_10',
        amount: 100,
        reward: 10,
    },
    {
        id: 'credits_25',
        amount: 249,
        reward: 25,
    },
    {
        id: 'credits_50',
        amount: 499,
        reward: 50,
    },
    {
        id: 'credits_75',
        amount: 749,
        reward: 75,
    },
    {
        id: 'credits_100',
        amount: 999,
        reward: 100,
    },
];

These products (which are loaded into a custom keyboard) are then hooked up to the invoice function (hearsFull is just a slightly modified version of the hears function which allows me to listen for messages with custom variables).

products.map((product) => {
    feature.filter(hearsFull('payments.productLabel', { amount: product.reward }), async (ctx: Context) => { // If bot hears `X Credits!`.
        sendPaymentInvoice(ctx, composer, 'USD', product.id as AvailableProducts, 'stripe', {}); // Sends an invoice for the matching product.
    });
});

Any help would be greatly appreciated as I honestly have no clue where the issue could be coming from.

KnorpelSenf commented 1 month ago

I am not sure that this will fix all of your code, but there is one mistake that stands out: you register further handlers on your composer inside an existing handler by calling composer.on a few times. This means that you alter the behaviour of your entire bot whenever an update is handled. Basically, whenever you run the code above, a new handler is added, so if you handle 5000 payments, then your bot now has 5000 handlers for ':successful_payment'. That surely is not what you meant, is it?

strbit commented 1 month ago

Yeah, that is definitely not the intended behaviour. Is there a way to see which handlers are added so I can perhaps try and see what's going on? Or do they not provide any useful information?

KnorpelSenf commented 1 month ago

There is no way to see this, but in general, you should never add more handlers after your bot has started. You may want to use something like https://grammy.dev/plugins/conversations for this.

strbit commented 1 month ago

Got it working by moving all of the handlers to my createBot function within the index.ts file (which runs prior to the bot start) and the :successful_payment to its own function. It's quite messy but it works like expected. Big thanks for the help!

/**
 * Answers the checkout query which is required to continue with the payment.
 * @see https://core.telegram.org/bots/api#answerprecheckoutquery
 */
protectedInstance.on('pre_checkout_query', async (ctx) => ctx.answerPreCheckoutQuery(true));

/**
 * Responds to a successful payment and updates the user's balance.
 */
protectedInstance.on(':successful_payment', async (ctx) => processSuccessfulPayment(ctx, getProductInfo(getUpdateInfo(ctx).message?.successful_payment!.invoice_payload as AvailableProducts)!));
KnorpelSenf commented 1 month ago

You can improve your code structure by defining things in modules, but registering things centrally. That is better than passing around composer instances to register middleware in many places. https://grammy.dev/advanced/structuring elaborates on this.

strbit commented 1 month ago

Thanks for the suggestion, I believe i'm already doing this with most of my modules but i'll check out the docs to make sure.