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.
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 |