B Botex Archive

Extensions / CardToCardReceipt

Card to Card Receipt 1.2.0

Card-to-card top-ups. The customer is shown the card to transfer to and sends a photo of the receipt, Claude reads the paid amount off it, and the admins get it with approve, decline, remove and ban buttons. One pending receipt per user until an admin acts.

older release stable 16 installs

You are looking at 1.2.0. The current release is 1.4.0.

Install

Install it

php bin/console ext:install CardToCardReceipt --version=1.2.0

Update it later

php bin/console ext:update CardToCardReceipt

Remove it

php bin/console ext:remove CardToCardReceipt

Add --dry-run to any of these to see exactly which files would change before anything is written.

Readme

CardToCardReceipt

Card-to-card top-ups. A customer is shown the card to transfer to, sends

a photo of the transfer, Claude reads the paid amount off it, and an

admin decides what actually lands in the wallet.

Two rules shape everything below:

  • Only an admin moves money. The model's answer is a suggestion

printed on the admin's screen and preloaded into one button. It never

credits anything on its own.

  • One pending receipt per customer. A second photo while the first is

unreviewed is refused rather than queued, so an admin is never looking

at three pictures of the same transfer.

---

It registers itself as a payment method (card_to_card) with core's

top-up system, so it appears in /topup alongside any other method and

an admin can switch it off from **/admin → Top-ups → 💳 Payment

methods**. Approvals are settled through TopUpService, which means the

money shows up attributed to this method on the financial report.

Installing

php bin/console ext:install CardToCardReceipt

Then two things, in this order:

Set the card number. Nothing else here needs configuring, and this

does. From Telegram — /admin → 🧾 رسیدها → 💳 اطلاعات کارت — three

buttons ask for the number, the name on the card and a support contact,

one message each. A single dash (-) clears a field.

The same three from the console, if you would rather:

php bin/console ext:set CardToCardReceipt card_number "6037997512345678"
php bin/console ext:set CardToCardReceipt card_holder "محمد رضایی"
php bin/console ext:set CardToCardReceipt support_contact "@yoursupport"

The panel exists because this is the one setting here that is urgent at

the moment it is wrong: a card expires on a Friday and every customer

who taps کارت به کارت until somebody reaches a console is being shown a

dead number.

Separators are stripped, so paste it however it is printed, and Persian

numerals are folded to ASCII. A value that cannot be a card number — too

short, letters, a half-paste — is refused in the panel and treated as

not set if it reached the file some other way: an unset card sends the

customer to support, a mistyped one sends their money to a stranger, and

only one of those is recoverable.

card_holder is optional but worth filling in. Every Iranian banking app

shows the holder's name before the transfer is confirmed, and it is the

only thing a customer can check that screen against.

support_contact is what the customer is pointed at while no card number

is set. Without it they are told to ask support without being told who

that is.

Then switch the method on: /admin → Top-ups → 💳 Payment methods.

A new payment method is off until somebody says otherwise — an extension

appearing and working is a convenience, a payment route appearing and

taking customers' money is not. That screen also prints what each method

says about itself, so a method that is on and still not taking money says

why on the line under its name.

It reports itself as unusable while no ADMIN_IDS are configured, since

every receipt it takes needs a human decision before a balance moves.

For the AI reading, put an Anthropic key in .env:

ANTHROPIC_API_KEY=sk-ant-...

That is deliberately not a setting. Settings are printed in full by

ext:settings and readable by anyone with the web panel, so a secret does

not belong in one. Without a key the extension still works: every receipt

reaches an admin, just with no suggested amount on it.

What the customer does

/topup and then this method, or /receipt directly — or the label

🧾 ارسال رسید. All three reach the same door, so the pending-receipt

lock and the wording cannot drift apart between them:

User: /receipt
Bot:  💳 کارت به کارت

      شماره کارت :
      6037 9975 1234 5678
      👤 به نام : محمد رضایی

      ✅ پس از واریز، دکمه‌ی 🧾 ارسال رسید را بزنید.

                                        [🧾 ارسال رسید]
User: [presses 🧾 ارسال رسید]
Bot:  📸 لطفاً عکس رسید را ارسال کنید.        [انصراف]
User: [photo]
Bot:  🔄 فیش واریزی شما در حال بررسی میباشد ...

Paying and proving it are two messages. The first one only says where

to send the money; nothing is asked until the customer presses the

button. Asking for the photo in that same message — which is what this

used to do — left an open question hanging over a transfer that had not

happened yet: the customer leaves for their banking app, comes back

several minutes later, and the bot has spent the whole time waiting for a

picture of something they had not paid.

The card number is sent as a code block, so tapping it copies it.

Retyping sixteen digits into a banking app is where a transfer goes to

the wrong person, and it is the one mistake here that no admin button can

undo.

With no card_number set, that first block is replaced by:

⚠️ شماره کارت هنوز تنظیم نشده است.

لطفاً شماره کارت را از پشتیبانی دریافت کنید : @yoursupport

✅ پس از واریز، دکمه‌ی 🧾 ارسال رسید را بزنید.

                                    [🧾 ارسال رسید]

The button is still offered underneath it, because somebody who got the

number from support last week can still pay and still needs somewhere to

send the proof.

The command is not on the main menu keyboard by default, because that

keyboard is core's and this extension does not touch core. To put it

there, add its label to src/Bot/Keyboard/MainMenuKeyboard.php:

MenuButton::make(\Extensions\CardToCardReceipt\Command\SendReceipt::button())

That last message then updates itself in place as the read finishes:

| What was read | What they see |

| --- | --- |

| an amount and a currency | ✅ … به مبلغ ۳۴۵,۰۰۰ تومان شناسایی شد |

| a number, no currency word | ✨ به علت عدم تشخیص نوع ارز … |

| nothing usable | ✨ … به عنوان فیش واریزی شناخته نشد |

| photo over the size ceiling | ✨ … جهت بررسی به ادمین ارسال شد |

None of these promises a credit. The admin has not decided yet, and a

customer who reads a number here will take it as a promise.

What the admin gets

A new receipt goes to one admin — the first id in admins, who owns

the queue. Everyone else reaches a receipt deliberately, from the queue

screen, which re-sends a still-pending one to whoever asks. Sending every

receipt to every admin turned one decision into four stale copies and

four people reading the same photo.

The photo, with a caption:

✅ رسید جدیدی ثبت شد.

👤 آیدی کاربر : 769583736 ( Mohammad )
💰 موجودی فعلی : 3,450 تومان

✨ : درسته. فیش واریزه
💵 مبلغ شناسایی شده : 345,000 تومان
🧾 روی فیش : 3,450,000 ریال

and five buttons:

| Button | Does | Customer hears |

| --- | --- | --- |

| ✅ تایید عدد هوش مصنوعی | credits the amount Claude read | approved, with the amount and new balance |

| ✅ تایید | asks the admin for an amount, then credits it | the same |

| ❌ | refuses the receipt | refused |

| 🗑 | deletes every admin copy | nothing |

| ⛔️ | refuses and blocks the sender | refused, and told they are blocked |

All five release the lock, so the customer can submit again — including

🗑, which is for a photo that was never a payment claim.

The first button is drawn only when there is a number to accept.

The report

The panel's front page is a report over a short horizon — today, the last

seven days, and a line per day:

🧾 گزارش رسیدها

⏳ در انتظار بررسی : 3

📅 امروز
📥 دریافتی : 12
✅ تایید : 9 — 1,250,000 تومان
❌ رد : 2   🗑 حذف : 1   ⛔️ مسدود : 0
✨ خوانده شده : 10 از 12   🎯 دقیق : 8 از 9

🗓 ۷ روز اخیر
📥 دریافتی : 74
...

📊 روزانه
2026-09-09 · 8 رسید · 6 تایید · 720,000 تومان
...

Three things worth knowing about how it counts:

  • One clock. Every row is counted by the day it arrived, not the day

it was decided, so a receipt belongs to exactly one day and the outcomes

always add back up to دریافتی. A report whose columns don't sum is one

an operator stops trusting.

  • ⏳ در انتظار بررسی sits outside both windows. Something submitted

nine days ago and never answered is still waiting today, and burying it

under a seven-day heading is how it stays unanswered.

  • خوانده شده and دقیق are different questions. The first is how

often the model produced a number at all; the second is how often an

admin approved for precisely that number — the only honest measure of

whether the suggestion is worth trusting.

Empty days still get a line, because a gap reads as "nothing came in",

which is information, where a missing row reads as a bug in the report.

There is no all-time total anywhere, and that is deliberate: it is the

one figure that would require keeping every receipt ever submitted. See

retention, below.

The other screen is ⏳ در انتظار بررسی, reachable from the reply

keyboard as well as the front page. It lists what is waiting; each row

re-sends that receipt's photo with a fresh set of buttons. That is how a

second admin picks something up, and it also covers the admin who deleted

their copy or was added after the fact.

The third is 💳 اطلاعات کارت, also on the keyboard, which shows the

card exactly as the customer will see it — grouped in fours, in a

copyable block — over three buttons that change it:

💳 اطلاعات کارت

شماره کارت : 6037 9975 1234 5678
نام صاحب کارت : محمد رضایی
پشتیبانی : @yoursupport

کاربر هنگام انتخاب «کارت به کارت» همین را می‌بیند.

[ ✏️ شماره کارت ] [ ✏️ نام صاحب کارت ] [ ✏️ راه ارتباط با پشتیبانی ]

Each button asks one question and writes one setting. The number is

validated before it is stored rather than on the way back out, so an

admin who mistypes is told so while they are still looking at what they

typed — the alternative is a "saved" that reads back as not set and

quietly sends every customer to support.

Which screen gets which kind of button follows the panel's rule: reaching

a screen is a keyboard label, changing what is on one is an inline button.

Retention

A receipt is a photograph of somebody's bank transfer, so settled ones are

deleted after keep_days (default 30) rather than kept forever.

The prune runs on submission — the traffic that grows the table is what

trims it — so it needs no job worker and no cron. A bot that stops taking

receipts stops pruning, which is correct: the table isn't growing either.

It is capped at 200 rows per pass, so a backlog clears over the next few

submissions instead of in one long DELETE behind a waiting customer.

Two things it never touches:

  • Pending receipts, at any age. Someone is still waiting, and no

amount of time makes that stop being true.

  • The money. Each approval writes a wallet entry referencing

cardtocardreceipt#<id>, and the ledger is append-only. Deleting the

receipt removes the photo reference, the AI reading and the decision —

not the record that the balance moved.

keep_days is floored at 7 so retention can never be shorter than the

report window, and ceilinged at 365.

How the safety works

The lock is a unique index on pending_key, not a check in PHP. Two

photos arriving together both pass any "is there a pending one?" read;

only one can survive the insert.

The settle is a conditional UPDATE … WHERE status = 'pending'. Two

admins pressing two different copies of the same receipt produce one

decision and one loser who is told so. The same statement clears the

lock, so a decision and a release cannot come apart.

The credit happens inside that same transaction, under an

idempotency key derived from the receipt id — cardtocardreceipt:<id>:credit.

If the credit fails the settle rolls back with it, so a receipt is never

marked paid for money that never landed.

The amount is bounded at both ends by min_amount / max_amount.

Both a model reading blurred digits and an admin typing on a phone fail

the same way, which is an extra zero.

The read refuses to guess. The prompt takes only the final paid total

and only a currency word actually printed next to it — never the tracking

number, never a currency inferred from magnitude, never a blurred digit

filled in from context. amount comes back as a digits-only string so

no JSON layer can normalise 0500 into 500.

Settings

| Key | Default | Meaning |

| --- | --- | --- |

| card_number | "" | the card customers transfer to; separators stripped, 12–19 digits or it counts as unset |

| card_holder | "" | the name on the card, shown so the customer can check it against what their banking app displays |

| support_contact | "" | who to ask when card_number is unset; empty still says "ask support", just without naming them |

| ask_text | "" | the line under the card details; empty uses the built-in one |

| decline_note | "" | optional line appended to a refusal |

| keep_days | 30 | days a settled receipt is kept before deletion; floored at 7, ceilinged at 365 |

| min_amount | 1000 | smallest credit an admin can approve, minor units |

| max_amount | 100000000 | largest credit an admin can approve, minor units |

| ai_enabled | true | off means every receipt arrives with no suggestion |

| ai_model | claude-sonnet-5 | |

| ai_max_file_size | 500000 | bytes above which the photo is handed straight to the admin |

| ai_timeout | 25 | seconds to wait on the model |

ask_text is appended to the card details rather than replacing them, so

an operator can add instructions — a minimum, a note about transfer

limits — without accidentally removing the only place the customer is

told where to pay.

Why the read runs in the webhook

It could be a job, and a job would keep the webhook fast. But a bot whose

operator has not started jobs:work would then take photos and silently

never show them to anybody, which is the one failure mode a review queue

must not have. So the row is written first — which is also what claims the

lock — the "being reviewed" message goes out immediately, and only then

does anything touch the network. If the model never answers, the receipt

still exists and still reaches an admin; the only thing lost is the

suggestion.

Tables

| Table | Holds |

| --- | --- |

| cardtocardreceipt_receipts | one row per submitted photo, from upload to decision |

| cardtocardreceipt_messages | one row per copy sent to an admin, so every copy can be edited or deleted |

Wallet entries written by an approval are deliberately left alone by

uninstall(): removing the extension is not a decision to unwind money

that was already credited.

Releases

Version Published Size Files
1.4.0 current 25 Sep 2026 73.7 KB 38
1.3.2 21 Sep 2026 73.1 KB 38
1.3.1 16 Sep 2026 72.2 KB 38
1.3.0 16 Sep 2026 63.2 KB 33
1.2.0 15 Sep 2026 60.6 KB 32
1.1.0 15 Sep 2026 57.8 KB 32
1.0.0 15 Sep 2026 50.8 KB 31