Payment System
How money works internally. For the endpoints a client calls, see payments-api.md; for Stripe Dashboard configuration, see stripe-setup.md; for the delivery plan and remaining phases, see payments-integration-plan.md.
Design in one paragraph
Every paid surface (courses, communities, sessions) creates a payment row before
the buyer is sent anywhere, and that row's id travels with the Stripe Checkout
Session. Settlement is driven by the webhook, never by the buyer returning to a
success URL. When money arrives, a settler for that payment's purpose translates
it into domain access. The domain tables remain the truth about access; the
payments table is the truth about money, and the two are reconciled, never
merged.
The two ledgers, and why they stay separate
| Truth about | Example | |
|---|---|---|
course_enrollments.status |
Access | pending_payment โ active โ refunded |
community_subscriptions.status |
Access | pending โ active โ expired |
session_bookings.status |
Access | confirmed โ paid โ refunded |
payments.status |
Money | pending โ succeeded โ refunded |
Merging them would lose information. A refunded payment and a revoked enrollment are two facts; collapsing them would erase the record that money changed hands and came back.
payments
Polymorphic over the three payable models. Notable columns:
referenceโ the human-quotable invoice number (INV-2026-000142), unique and sequential within the calendar year. Assigned byPayment::nextReference()on create; the uuididstays the system identifier. Stored rather than derived, because an invoice number that changes when the derivation changes is worse than none at all.amount_minorโ integer minor units, Stripe's unit. Never a decimal.currencyโ lowercase ISO-4217, constrained by theCurrencyenum.platform_fee_minorโ recorded on every payment even though the platform currently banks 100%. Backfilling a fee split onto historical rows later is far worse than storing a column that is not yet acted on.destination_account_id,application_fee_minorโ unused; present so Stripe Connect is a code change rather than a data migration.refund_required_at/refund_required_reasonโ the admin refund queue.stripe_checkout_session_id,stripe_payment_intent_id,stripe_invoice_idโ each unique. These constraints are the idempotency guarantee.
No card data is stored, deliberately. No brand, last four, expiry or receipt
URL column exists. Stripe is the vault and its charge object is immutable, so
PaymentGateway::retrieveChargeDetails() reads them back on demand โ a snapshot
of what paid, without a second thing to keep in step with the webhook. Only the
invoice detail screen asks, never a list, and the result is cached because a
charge cannot change.
That cache holds ChargeDetails::toArray(), never the object:
cache.serializable_classes is false, so anything else reads back as
__PHP_Incomplete_Class on the next request. See billing-api.md.
stripe_events
Every webhook delivery, recorded before it is acted on. stripe_event_id is unique,
so the insert itself is the dedup check.
Idempotency โ three independent layers
Stripe guarantees at-least-once delivery and may deliver out of order. Every event will eventually arrive twice. Three separate guards:
stripe_events.stripe_event_idis unique. A redelivered event collides on insert and does no work. Using the write rather than a read-then-write removes the race between two concurrent deliveries.PaymentServicerefuses terminal transitions.markSucceededon an already settled payment returns early โ this catches a different event arriving for a payment that was already reconciled, which layer 1 cannot see.- Settlers are idempotent by contract.
PaymentSettlerrequires it explicitly:activateAfterPaymentchecks the enrollment is stillpending_paymentbefore incrementing counts or awarding XP.
Layer 3 is the one that matters most, because it is what stops an enrollment count or an XP award from being applied twice.
Components
Domain service โโโถ PaymentService โโโถ PaymentGateway โโโถ Stripe
(course, etc.) โ (or FakeGateway)
โผ
PaymentSettlerRegistry
โ
โผ
PaymentSettler (per purpose) โโโถ back into the domain service
| Class | Responsibility |
|---|---|
App\Helpers\Money |
decimal โ minor units; the only place that arithmetic lives |
Services\Payment\PaymentService |
orchestration: open checkout, settle, fail, abandon, refund |
Services\Payment\Contracts\PaymentGateway |
the gateway seam |
Services\Payment\StripeGateway |
real Stripe; wraps every SDK call so ApiErrorException never escapes |
Services\Payment\FakeGateway |
offline, deterministic; bound when no Stripe key is present |
Services\Payment\StripeWebhookService |
verify โ dedupe โ dispatch |
Services\Payment\PaymentSettlerRegistry |
maps PaymentPurpose โ settler |
Services\Payment\Settlers\* |
translate money events into domain state |
Jobs\Payment\ProcessStripeWebhookJob |
settles off the request cycle, queue payments |
Services\Billing\InvoiceQueryService |
the ledger as a buyer or seller reads it |
Services\Billing\InvoiceScope |
which side of a transaction a request is on |
Services\Billing\InvoiceChargeResolver |
on-demand card snapshot, cached |
Adding a fourth paid surface
- Implement
Payableon the model (amount, currency, description, payer) โ data only. - Add a
PaymentPurposecase. - Write a
PaymentSettlerthat delegates into the existing domain service. - Register it in
AppServiceProvider::registerPaymentSettlers().
Nothing in the webhook pipeline changes.
Why settlers, rather than methods on the model
Guideline 03 keeps business logic out of models, but the stronger reason is that
settlement rules already live in the domain services โ CourseEnrollmentService
owns enrollment counts, XP, and the access predicate. A settler is a thin adapter
between the money vocabulary (settle / abandon / reverse) and the domain's own, so
neither side grows a copy of the other's rules.
Webhook path
POST /stripe/webhook
โ verify Stripe-Signature (refuses outright if no secret is configured)
โ INSERT stripe_events (unique id = dedup)
โ return 200 immediately
โ ProcessStripeWebhookJob (queue: payments, 5 tries, backoff 10sโ15m)
โ PaymentService transition
โ PaymentSettler โ domain
Returning 200 before doing the work is deliberate. Stripe retries anything slower than its timeout, so settling inline would turn one slow settlement into a storm of duplicate deliveries.
Response codes: 400 for an unverifiable signature (permanent โ stops Stripe
retrying), 500 if the event could not be recorded (asks Stripe to redeliver a
genuine event we failed to store).
Security
Signature verification is a refusal, not a bypass, when STRIPE_WEBHOOK_SECRET
is missing. An unverified webhook endpoint is a remote "mark this order paid"
button: anyone who learned the URL could grant themselves a course, a membership, or
a session. This is the only CSRF-exempt route in the application.
Money handling
Every price column in this codebase is decimal(10,2) โ major units. Stripe deals
in integer minor units. Money is the single conversion point.
Money::toMinor('49.99', Currency::Usd); // 4999
Money::toMajor(4999, Currency::Usd); // '49.99'
Money::percentageOf(4999, 15); // 749 (floored)
Two traps this closes:
- Zero-decimal currencies. Stripe wants ยฅ1000 as
1000, not100000.Currency::minorUnitFactor()must never be assumed to be 100. - Float drift.
49.99 * 100is4998.9999โฆin binary floating point; truncating would bill a cent short on every sale at that price.
The platform fee is floored, so the rounding remainder always favours the seller and the platform can never take more than its stated percentage.
Currency::fromLoose() is the validation boundary โ the price columns are free-text
string(10) with no allowlist, so nothing reaches Stripe unless it resolves to a
supported currency.
Logging
Payment code calls Log:: directly and never log_entry().
log_entry() โ and therefore error_response()'s logging โ is a no-op when
APP_DEBUG=false. That is exactly the production configuration where a failed
settlement most needs a record.
Testing
The whole suite runs with no network and no Stripe credentials.
FakeGatewayis bound wheneverservices.stripe.enabledis false, which covers the test environment. It returns deterministic ids and a signed local URL.- Webhook signatures are real.
stripeSignature()intests/Pest.phpcomputes a genuine Stripe-format HMAC against a test secret, so the real verification path is exercised rather than stubbed past. postStripeWebhook()/stripeEvent()build and post signed events.Tests\Support\Payment\RecordingSettlerrecords settle/abandon/reverse calls, letting the payment core be tested without dragging a domain in.
php artisan test --compact --filter=StripeWebhookTest # core: signatures, replay, dispatch
php artisan test --compact --filter=MoneyTest # conversion + fee arithmetic
php artisan test --compact --filter=CoursePaymentTest # end-to-end course purchase
Payments in the fake gateway record provider = fake, so a locally-settled payment
can never be mistaken for real money or sent to Stripe's refund API.
Configuration
STRIPE_SK= # empty โ FakeGateway is bound
STRIPE_WEBHOOK_SECRET= # required in production; empty rejects all webhooks
PAYMENTS_FAKE_GATEWAY=false # force the fake gateway even with a key present
APP_FRONTEND_URL= # base for Stripe's success/cancel redirects
Platform fee comes from platform_settings.platform_fee_percentage (seeded at 15),
read through the cached PlatformSettingService โ the underlying model has no
caching, and this is resolved on every checkout.
Automatic booking refunds
Session bookings refund themselves. How much is decided by
Services\Booking\CancellationPolicyService, which owns the refund policy (ยง8.2) and answers
with a CancellationOutcome โ a percentage plus the RefundReason to record. It reads three
things: whether the session was ever agreed, who is cancelling, and how long until it starts.
BookingManagementService::cancel() asks it before applying the cancellation, because both
the status and the clock are inputs and both are gone once the booking reads CANCELLED. The
percentage is applied to amount_minor โ the original charge โ and only then clamped to what
is still refundable; taking a percentage of the remainder would quietly shrink a refund
whenever any of the charge had already gone back.
| Outcome | Refund |
|---|---|
Cancelling out of PAID (mentor declined, no response, or mentee withdrew) |
100% |
Mentor cancels a CONFIRMED session |
100%, however late |
Platform cancels a CONFIRMED session |
100% |
Mentee cancels a CONFIRMED session before cancellation_window_hours |
100% โ cancellation_fee_percentage |
Mentee cancels a CONFIRMED session inside that window |
nothing |
| Group-seat capacity race | 100% (capacity_conflict) |
Both thresholds are admin-editable platform settings (48 hours and 25% as seeded). Rounding
favours the mentee: CancellationOutcome::amountFor() floors the withheld share, not the
refunded one.
Every outcome that owes money dispatches App\Jobs\Booking\IssueBookingRefund
(->afterCommit()) carrying the amount, which calls PaymentService::refund(). The amount is
carried rather than recomputed in the job: by the time it runs, the booking reads CANCELLED
and the clock has moved.
The job exists rather than a direct call for three reasons: settlement runs inside
markSucceeded()'s open transaction, and a gateway round-trip in a transaction that may roll
back would refund money the database then forgets; a mentor's decline must not be hostage to
Stripe latency; and one expiry sweep can owe many refunds at once. On exhausted retries the
job's failed() falls back to flagForRefund(), so nothing goes quiet.
The booking is marked cancelled immediately and reaches refunded only through the money
path (recordRefund() โ settler reverse() โ markRefunded()), so the mentee is never told
funds are on the way when they are not. A partial refund stops at cancelled: part of the
charge stayed with the mentor, so refunded would be a lie. It routes to
reversePartially() instead, which revokes nothing and sends the mentee a
RefundIssuedNotification carrying what actually came back.
Refunds a person has to decide
Two rows of the policy cannot be automated โ a platform failure, and a session disputed by the
mentee โ and a third case arises when the gateway refuses an automatic refund and
flagForRefund() parks it. All three are served by
POST /api/v1/admin/transactions/{id}/refund, which delegates the money to the same
PaymentService::refund() and records the actor, the amount and a mandatory note in
admin_audit_logs (transaction.refunded).
The audit entry is written after the state change rather than inside a transaction with it,
which is the one place the platform breaks its own audit rule. A gateway call cannot be rolled
back by failing a later insert, and logging first would record refunds that never happened;
payments.refunded_at is the backstop if the log write is what fails.
One currency per mentor
A mentor picks one currency and every amount they type is in it. There is no FX rate anywhere in this system, so nothing is ever converted or restated โ a switch changes what a mentor may write next, never what a stored amount means.
Derived, never requested. currency is absent from the rules of every catalogue write
request (session types, courses, course pricing, offers, coupons, communities). A client
still sending one has it stripped by validation; the service stamps
MentorProfile::currencyCode() instead. Rules, offers and codes hanging off a course take
the course's currency, so a discount can never disagree with the price it applies to.
Restamping happens on create, and on update only when the payload actually touches the amount. An unrelated edit โ a title, a window, an active flag โ leaves the currency alone, because restamping there would silently redenominate a legacy row: GBP 50 becoming USD 50 with nothing on screen to say so.
The picker is USD and BDT (Currency::mentorSelectable()). Every other case on the
enum stays chargeable โ fromLoose() resolves it and an item already priced in one keeps
billing โ but no one may switch to it. A mentor already sitting on a retired currency keeps
it: Currency::selectableCodesFor() unions the picker with their own code, or the Settings
form would 422 on every save over a field they never touched.
The switch is locked while anything is priced. MentorCurrencyLock refuses a change
while the mentor holds a priced session type, course, pricing rule or community, a
fixed-amount discount, or an open withdrawal. The 422 carries per-kind counts under
data.blockers; GET /mentor/profile/currency reports the same up front so the picker can
be disabled rather than the rule being discovered by provoking an error. Re-saving the
same currency is always allowed.
Withdrawals ask two questions, not one. minimum_withdrawal_<code>_minor says whether
the platform pays out in a currency at all; payout_method_types.currencies says whether
the chosen rail can carry it. Both are checked at request time โ a USD withdrawal to a
BDT-only wallet is a 422 keyed on payout_method_id, not a hand-rejection days later. A
rail with no allowlist (null) accepts anything, which today makes card the only
USD-capable rail; bKash, Nagad and bank are all seeded ['bdt'].
Two representations, one bridge. Catalogue rows store decimal(10,2) plus an uppercase
string(10) currency โ the quote. The ledger (payments, mentor_withdrawals) stores
*_minor integers plus a lowercase char(3) โ the money. They agree at creation and are
never recomputed from each other; Money::toMinor() is the only crossing.
The two halves are presented differently, on purpose. The ledger emits the
{minor, major, formatted} block via ExposesMoney, because amount_minor is stored in
minor units and every consumer would otherwise divide by 100 itself. The catalogue emits
its decimal(10,2) string as-is, beside a currency code.
Catalogue resources add one integer beside it โ price_minor (or
price_snapshot_minor, discount_value_minor) via ExposesPriceMinor. That is worth
publishing because the conversion is not a constant: multiplying by 100 client-side is
correct until the first zero-decimal currency, where it is wrong by a hundredfold.
A fuller {minor, major, formatted} block was briefly published here and removed: major
came back byte-identical to the price already in the payload, and formatted was a crude
CODE 0.00 no interface would render. The ledger keeps the full block, because
amount_minor really is stored in minor units and has no decimal string to publish.
Keys follow each resource's own casing โ price_minor in the snake_case resources,
priceMinor in the camelCase booking ones.
Known gaps
- Coupons are still inert.
community_couponscan be managed by owners, but no discount is computed or applied at checkout andredemptions_countis never incremented. Wiring them to Stripe Promotion Codes is the remaining phase โ see payments-integration-plan.md. - No mentor-facing platform fee.
platform_fee_minoris written on every payment but surfaces only inAdminTransactionResource. Whether a mentor may see the cut taken from their own sales is an open product decision, so/mentor/invoicesreports gross only. Note thatneton the admin resource isamount โ refunded, notamount โ platform_fee. - No saved payment methods. A Stripe Customer exists per user
(
users.stripe_customer_id,ensureCustomer()), but checkout is one-off and attaches no reusable payment method, so there is nothing to list, set as default, or detach. - A partial refund never reverses its purchase.
recordRefund()calls the settler'sreverse()only on a full refund; a partial one goes toreversePartially(), which by design takes nothing away. Deliberate: the buyer still holds part of what they bought. For a session booking that hook is what tells the mentee a cancellation fee was withheld โ without it an early cancellation would refund 75% in silence. - A superseded checkout is only cancelled locally.
cancelOutstandingFor()marks the oldPaymentrow cancelled but never tells Stripe to expire the session. If that older session then settles,markSucceeded()sees a final status and returns silently. Pay-first makes reopening checkout an ordinary path, so this is worth closing.