MeetyyAPI
Documentation / API Reference / Payment System

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 by Payment::nextReference() on create; the uuid id stays 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 the Currency enum.
  • 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:

  1. stripe_events.stripe_event_id is 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.
  2. PaymentService refuses terminal transitions. markSucceeded on an already settled payment returns early โ€” this catches a different event arriving for a payment that was already reconciled, which layer 1 cannot see.
  3. Settlers are idempotent by contract. PaymentSettler requires it explicitly: activateAfterPayment checks the enrollment is still pending_payment before 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

  1. Implement Payable on the model (amount, currency, description, payer) โ€” data only.
  2. Add a PaymentPurpose case.
  3. Write a PaymentSettler that delegates into the existing domain service.
  4. 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, not 100000. Currency::minorUnitFactor() must never be assumed to be 100.
  • Float drift. 49.99 * 100 is 4998.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.

  • FakeGateway is bound whenever services.stripe.enabled is false, which covers the test environment. It returns deterministic ids and a signed local URL.
  • Webhook signatures are real. stripeSignature() in tests/Pest.php computes 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\RecordingSettler records 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_coupons can be managed by owners, but no discount is computed or applied at checkout and redemptions_count is never incremented. Wiring them to Stripe Promotion Codes is the remaining phase โ€” see payments-integration-plan.md.
  • No mentor-facing platform fee. platform_fee_minor is written on every payment but surfaces only in AdminTransactionResource. Whether a mentor may see the cut taken from their own sales is an open product decision, so /mentor/invoices reports gross only. Note that net on the admin resource is amount โˆ’ refunded, not amount โˆ’ 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's reverse() only on a full refund; a partial one goes to reversePartially(), 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 old Payment row 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.