Skip to content

Commit aa8a5e5

Browse files
committed
feat: Enhance billing and transaction handling
- Added methods to InvoiceRepositoryInterface for retrieving the latest, pending, and overdue invoices for a subscription. - Introduced TransactionRepositoryInterface for managing transaction records. - Created events for payment failures, successful payments, and refunds to facilitate better event-driven architecture. - Implemented new methods in BillingService for handling invoice payments, failed payments, and refunds, ensuring proper state transitions and event dispatching. - Updated Invoice and Transaction models to support new functionalities, including refund tracking. - Added tests for invoice queries and transaction recording to ensure correctness of new features and idempotency of payment processing.
1 parent 742c2b3 commit aa8a5e5

33 files changed

Lines changed: 1280 additions & 166 deletions

CHANGELOG.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Transaction ledger API**`BillingService::recordPayment()`,
13+
`recordFailedPayment()`, and `recordRefund()` (via `Tashil::billing()`) record the
14+
payments/refunds the host's gateway executes, each writing the `Transaction`
15+
audit row **and** reflecting the invoice state in one DB transaction. Tashil
16+
still never moves money — these only *record* what the host reports.
17+
- `recordPayment()` writes a `success` transaction and marks the invoice paid
18+
(routing through `InvoiceObserver` → activate / advancePeriod / reactivate).
19+
- `recordFailedPayment()` writes a `failed` transaction and leaves the invoice
20+
`Pending` for dunning.
21+
- `recordRefund()` accumulates `refunded_amount` (partials supported); a full
22+
refund flips the transaction to `Refunded` and the invoice to `Refunded`.
23+
- `recordPayment` / `recordFailedPayment` are idempotent on
24+
`UNIQUE(gateway, transaction_id)` — a replayed at-least-once webhook resolves
25+
to the existing row and never double-settles.
26+
- **`TransactionRepositoryInterface` + `EloquentTransactionRepository`** — the
27+
transaction ledger now sits behind an overridable repository like every other
28+
persistence concern.
29+
- **Events** `PaymentRecorded`, `PaymentFailed`, `PaymentRefunded` — carry the
30+
transaction + invoice, dispatched after commit.
31+
- **`Invoice::markAsRefunded()` / `Invoice::isRefunded()`** and a
32+
`gateway_response` array cast on `Transaction`.
33+
- **Invoice/transaction read API** on `BillingService` (`Tashil::billing()`):
34+
`latestInvoice($sub, ?InvoiceKind)`, `pendingInvoice($sub)`,
35+
`overdueInvoice($sub)`, and `successfulTransaction($invoice)` — so host code
36+
reads invoices through the (overridable) repository instead of querying the
37+
`Invoice` model directly. Backed by new `InvoiceRepositoryInterface` methods
38+
(`latestForSubscription`, `pendingForSubscription`, `overdueForSubscription`)
39+
and `TransactionRepositoryInterface::latestSuccessfulForInvoice`; these read
40+
paths are uncached (per-subscription invoices change on every payment/dunning
41+
step).
42+
43+
### Changed
44+
45+
- Cookbook examples (`PaymentWebhookController`, `ChargeRenewalInvoice`,
46+
`DunningListeners`) now use `recordPayment()` / `recordFailedPayment()` instead
47+
of hand-written `Transaction::create()` + `markAsPaid()`; added a
48+
`RefundController` example. `CheckoutController`, `TrialController`, and
49+
`SuspensionController` now read invoices via the billing API instead of direct
50+
`Invoice` queries. Billing docs document the ledger + refund flow and the read
51+
API.
52+
53+
### Fixed
54+
55+
- **`TransactionIdGenerator` uniqueness is now scoped to the gateway.** It takes
56+
the row's gateway via a new constructor argument (passed by
57+
`TransactionObserver`) and checks the composite `(gateway, transaction_id)`
58+
matching the DB constraint — instead of the bare `transaction_id`. The old
59+
global check wrongly rejected (and regenerated) an id that already existed
60+
under a *different* gateway; the same id can legitimately exist per gateway.
61+
Custom transaction generators may declare a `string $gateway` constructor
62+
parameter to receive it.
63+
1064
## [1.0.0-beta] - 2026-06-05
1165

1266
First public beta. Subscription + feature management for Laravel with an

CLAUDE.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ AI working guide for `foysal50x/tashil`. Read before touching code.
44

55
## What this package is
66

7-
Laravel subscription + feature management. **Owns** plan catalog, subscription state, feature gating, atomic usage counters, trial lifecycle, scheduled state transitions, invoice issuance, the **activate-on-payment → renewal → dunning → reactivation** state machine, **proration on in-place plan changes**, immutable event log, and route middleware for gating. **Does NOT own** payment capture, the actual retry *charge* during dunning, refund execution, gateway sync, or the wallet/balance that funds Metered features — host app handles money movement and binds `MeteredBilling` for metered billing. Read [docs/09-Billing-Lifecycle.md](docs/09-Billing-Lifecycle.md) before touching activation / renewal / dunning / proration code.
7+
Laravel subscription + feature management. **Owns** plan catalog, subscription state, feature gating, atomic usage counters, trial lifecycle, scheduled state transitions, invoice issuance, the **transaction ledger** (recording the payments/refunds the host executes), the **activate-on-payment → renewal → dunning → reactivation** state machine, **proration on in-place plan changes**, immutable event log, and route middleware for gating. **Does NOT own** payment capture, the actual retry *charge* during dunning, refund execution, gateway sync, or the wallet/balance that funds Metered features — host app handles money movement, reports the result via `Tashil::billing()->record*()`, and binds `MeteredBilling` for metered billing. Read [docs/09-Billing-Lifecycle.md](docs/09-Billing-Lifecycle.md) before touching activation / renewal / dunning / proration code.
88

99
**Billing model:** a priced plan (`price > 0`, `requires_payment = true`) subscribes as `Pending` with NO access; an `initial` invoice is issued, and `activate()` runs when it's paid (anchoring the period to `paid_at`). Free / `requires_payment = false` plans subscribe `Active` immediately. Trials subscribe `OnTrial` (access granted) and bill at `convertTrial()`. Gating is decided by the **package's own `requires_payment` flag**`tashil.billing.activate_on_payment` (default `true`) only seeds that flag at creation when the caller doesn't set it; flipping the config later affects only new packages. Create packages with `requires_payment = false` for the legacy "access first, bill on renewal" model.
1010

@@ -178,6 +178,7 @@ Adding a feature ⇒ add a Feature test. Fixing a bug ⇒ add a regression test
178178
|---|---|
179179
| New subscription state transition | `SubscriptionService` method + `EventStore::append` + event class + state machine doc |
180180
| Activation / renewal / dunning / proration | `SubscriptionService` (`activate`/`reactivate`/`markPastDue`/`suspend`/`changePlan`), `InvoiceObserver` routing, `ProcessDunningCommand` + [docs/09-Billing-Lifecycle.md](docs/09-Billing-Lifecycle.md) |
181+
| Recording payments / refunds / failures | `BillingService` (`recordPayment`/`recordFailedPayment`/`recordRefund`), `Payment*` event, `TransactionRepositoryInterface`, `Invoice::markAs*` + [docs/09-Billing-Lifecycle.md](docs/09-Billing-Lifecycle.md) (Transaction ledger). Keep it record-only — no gateway calls |
181182
| New feature type | `FeatureType` enum + `FeatureBuilder::xxx()` helper + `Feature::isXxx()` + `UsageService::check`/`increment` + `EloquentSubscriptionRepository::syncFeatures` (limit_value rules) + doc 02 |
182183
| New scheduled job | `src/Console/` command + wire in `TashilServiceProvider::registerSchedule()` + doc 04 |
183184
| Custom invoice / transaction id | Implement `generate(): string` (extend `TokenizedIdGenerator` for token format reuse) + bind in `config/tashil.php` `invoice.generator` / `transaction.generator` |
@@ -189,16 +190,29 @@ Adding a feature ⇒ add a Feature test. Fixing a bug ⇒ add a regression test
189190

190191
```
191192
Tashil issues Invoice (status=pending) → fires InvoiceIssued
192-
Host listener charges via gateway → on success calls $invoice->markAsPaid()
193+
Host listener charges via gateway → on success calls
194+
Tashil::billing()->recordPayment($invoice, gateway, transactionId)
195+
→ writes Transaction(status=success) + $invoice->markAsPaid() (one DB tx)
193196
Tashil InvoiceObserver (status → Paid):
194-
→ SubscriptionService::advancePeriod($sub)
197+
→ SubscriptionService::advancePeriod($sub) (or activate / reactivate, by kind+status)
195198
→ EventStore::append('subscription.renewed')
196199
→ dispatch SubscriptionRenewed + InvoicePaid
200+
→ BillingService dispatches PaymentRecorded (after commit)
197201
```
198202

199-
For dunning / webhook reconciliation / refunds, host wires equivalent listeners. Tashil ends at issuing the bill and reflecting the host's `markAsPaid` decision.
203+
For dunning / webhook reconciliation / refunds, host wires equivalent listeners. Tashil ends at issuing the bill and recording the host's reported result (payment / failure / refund); it never charges or refunds at the gateway.
200204

201-
Transactions: pass gateway-supplied id through (`ch_…`, `txn_…`). `UNIQUE(gateway, transaction_id)` on `tashil_transactions` makes duplicate webhook deliveries safe — catch `UniqueConstraintViolationException` as "already recorded". For cash / manual entries, leave `transaction_id` empty — `TransactionObserver::creating` stamps `TXN-…` from `tashil.transaction.generator`.
205+
Transaction ledger: record money events through `Tashil::billing()` — don't hand-roll `Transaction::create()` + `markAsPaid()`:
206+
207+
- `recordPayment($invoice, …)``success` txn + `markAsPaid()` (routes activate/advance/reactivate) + `PaymentRecorded`.
208+
- `recordFailedPayment($invoice, …)``failed` txn, invoice stays `Pending` for dunning + `PaymentFailed`.
209+
- `recordRefund($transaction, …)` → accumulates `refunded_amount`; full refund flips txn→`Refunded` + invoice→`Refunded` + `PaymentRefunded`. Records a host-executed gateway refund; never executes one.
210+
211+
`recordPayment` / `recordFailedPayment` are idempotent on `UNIQUE(gateway, transaction_id)` — a replayed at-least-once webhook resolves to the existing row (no double-settle), so the host no longer hand-catches `UniqueConstraintViolationException`. Pass the gateway id (`ch_…`, `txn_…`) verbatim; leave it null for cash/manual entries and `TransactionObserver::creating` stamps `TXN-…` from `tashil.transaction.generator` (uniqueness checked within the row's gateway — `TransactionIdGenerator` takes the gateway via its constructor and checks the composite `(gateway, transaction_id)`, so the same id under a different gateway is allowed). The ledger sits behind `TransactionRepositoryInterface` (Eloquent default, append-only, uncached). `Invoice::markAsPaid/markAsVoid/markAsRefunded` remain as low-level transitions for hosts that record the transaction themselves.
212+
213+
Invoice/transaction reads also go through `Tashil::billing()` — don't query the `Invoice` model directly in host code:
214+
- `latestInvoice($sub, ?InvoiceKind)`, `pendingInvoice($sub)` (outstanding), `overdueInvoice($sub)` (pending + past due), `successfulTransaction($invoice)` (the charge a refund targets).
215+
- These resolve through `InvoiceRepositoryInterface` / `TransactionRepositoryInterface` and are intentionally **not** cached (per-subscription invoices change on every payment/dunning step). New per-subscription invoice read ⇒ add to the interface + Eloquent + Cache decorator (pass-through), then expose on `BillingService`.
202216

203217
### Metered billing contract
204218

@@ -255,9 +269,9 @@ Host overrides via `Tashil::resolveSubscribableUsing(fn () => Team::current())`
255269

256270
## Out of scope — don't add
257271

258-
- Card capture, payouts, refund execution, gateway sync.
272+
- Card capture, payouts, refund **execution**, gateway sync. (Tahsil *records* payments/refunds the host already executed — `recordPayment` / `recordRefund` — but never calls a gateway. Keep `record*` a pure ledger + invoice-state reflection; don't add a gateway client, charge, or refund call here.)
259273
- The actual retry *charge* during dunning, and webhook reconciliation logic. (Tahsil owns the dunning *state machine + schedule*`tashil:process-dunning` — and fires the events; the host performs the charge. Don't add charging here.)
260-
- Hash-chained financial ledger.
274+
- Hash-chained financial ledger. (The `tashil_transactions` ledger is a plain audit table, not a tamper-evident chain.)
261275
- Coupon / discount engine.
262276
- Wallet / balance / account ledger — Metered features delegate to host via `MeteredBilling`. Don't introduce balance tables here.
263277
- MRR waterfall (new/expansion/contraction/churn) beyond `AnalyticsService`.
@@ -287,7 +301,11 @@ Tashil::package('pro')->name('Pro')->price(29)->monthly()->trialDays(14)
287301
// Priced plan → Pending until the initial invoice is paid (then auto-activates).
288302
$sub = Tashil::subscription()->subscribe($user, $package);
289303
Tashil::subscription()->subscribe($user, $package, withTrial: true); // OnTrial, access now
290-
// Host charges → $invoice->markAsPaid() → InvoiceObserver → activate()
304+
// Host charges → Tashil::billing()->recordPayment($invoice, gateway, txnId) → InvoiceObserver → activate()
305+
306+
Tashil::billing()->recordPayment($invoice, gateway: 'stripe', transactionId: 'ch_…'); // success txn + settle (idempotent)
307+
Tashil::billing()->recordFailedPayment($invoice, gateway: 'stripe'); // failed txn; stays pending for dunning
308+
Tashil::billing()->recordRefund($transaction, amount: 12.50, reason: '…'); // host-executed refund; full → invoice Refunded
291309

292310
Tashil::subscription()->convertTrial($sub); // anchors + first invoice
293311
Tashil::subscription()->changePlan($sub, $newPackage); // in-place: upgrade prorates, downgrade defers

README.md

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ It **does not charge** — payment capture, dunning retries, refunds, and gatewa
1919
- [Subscriptions](#subscriptions)
2020
- [Feature System](#feature-system)
2121
- [Trial System](#trial-system)
22-
- [Invoices](#invoices)
22+
- [Invoices & transactions](#invoices--transactions)
2323
- [Scheduler](#scheduler)
2424
- [Events](#events)
2525
- [Analytics & Reporting](#analytics--reporting)
@@ -374,22 +374,42 @@ See [docs/03-Trial-System.md](docs/03-Trial-System.md).
374374

375375
---
376376

377-
## Invoices
377+
## Invoices & transactions
378378

379-
Tahsil issues invoices on subscribe (non-trial) and renewal. The host charges and marks them paid.
379+
Tahsil issues invoices on subscribe (non-trial) and renewal, and keeps a **transaction ledger** of the payments/refunds the host reports. The host moves the money; Tahsil records it and reflects the invoice state.
380380

381381
```php
382382
$invoice = Tashil::billing()->generateInvoice($subscription);
383-
// host charges via gateway, then:
384-
$invoice->markAsPaid(); // InvoiceObserver advances current_period_end
385-
// and dispatches SubscriptionRenewed + InvoicePaid
383+
384+
// host charges via gateway, then records the result in ONE call:
385+
Tashil::billing()->recordPayment($invoice, gateway: 'stripe', transactionId: 'ch_…');
386+
// → writes a success Transaction + marks the invoice paid (InvoiceObserver
387+
// advances/activates by kind) + fires PaymentRecorded & InvoicePaid.
388+
// Idempotent on UNIQUE(gateway, transaction_id) — safe for replayed webhooks.
389+
390+
// declined charge → audit only; invoice stays pending for dunning:
391+
Tashil::billing()->recordFailedPayment($invoice, gateway: 'stripe');
392+
393+
// refund the host already executed at the gateway (full or partial):
394+
Tashil::billing()->recordRefund($transaction, amount: 12.50, reason: 'customer request');
395+
// → partial keeps Success/Paid; full flips Transaction→Refunded + Invoice→Refunded; fires PaymentRefunded.
396+
```
397+
398+
Read invoices/transactions through the billing API instead of querying the `Invoice` model directly — the access path stays behind the (overridable) repository:
399+
400+
```php
401+
Tashil::billing()->latestInvoice($subscription); // most recent invoice
402+
Tashil::billing()->latestInvoice($subscription, InvoiceKind::Initial); // most recent of a kind
403+
Tashil::billing()->pendingInvoice($subscription); // outstanding bill to pay (or null)
404+
Tashil::billing()->overdueInvoice($subscription); // pending + past due (or null)
405+
Tashil::billing()->successfulTransaction($invoice); // the settled charge a refund targets
386406
```
387407

388-
Invoice statuses: `draft`, `pending`, `paid`, `void`, `refunded`.
408+
`Invoice::markAsPaid()` / `markAsVoid()` / `markAsRefunded()` remain as low-level state transitions, but `recordPayment` / `recordRefund` are the complete, idempotent path (they also write the transaction row). Invoice statuses: `draft`, `pending`, `paid`, `void`, `refunded`. Transaction statuses: `pending`, `success`, `failed`, `refunded`.
389409

390410
Invoice numbers are generated by `tashil.invoice.generator` — default `InvoiceNumberGenerator` parses the format string `#-YYMMDD-NNNNNN` (e.g. `INV-260522-849021`).
391411

392-
Both built-in generators implement `Foysal50x\Tashil\Contracts\ShouldBeUnique` — they pre-check the rendered id against the live table (`invoice_number` / `transaction_id`) and re-render on a hit, bounded by `maxGenerationAttempts()` before throwing `UniqueIdGenerationException`. The pre-check only narrows the window; the DB unique constraint is the real guarantee under concurrency, and the host owns retry on an actual collision. Supply your own generator to change the uniqueness scope, or implement `generate()` without the contract to skip the check. See [docs/06-Developer-Guide.md › Guaranteed-unique ids](docs/06-Developer-Guide.md#guaranteed-unique-ids).
412+
Both built-in generators implement `Foysal50x\Tashil\Contracts\ShouldBeUnique` — they pre-check the rendered id against the live table (`invoice_number` globally; `TransactionIdGenerator` against the composite `(gateway, transaction_id)`, so the same id under a different gateway is allowed) and re-render on a hit, bounded by `maxGenerationAttempts()` before throwing `UniqueIdGenerationException`. The pre-check only narrows the window; the DB unique constraint is the real guarantee under concurrency, and the host owns retry on an actual collision. Supply your own generator to change the uniqueness scope, or implement `generate()` without the contract to skip the check. See [docs/06-Developer-Guide.md › Guaranteed-unique ids](docs/06-Developer-Guide.md#guaranteed-unique-ids).
393413

394414
---
395415

@@ -434,6 +454,7 @@ All events dispatch after `DB::afterCommit()` so listeners never see torn state
434454
| `MeteredCharged` | Metered `useFeature()` charge accepted — carries units, unit price, amount, currency. |
435455
| `MeteredChargeRejected` | Metered charge declined (insufficient balance / provider refusal). |
436456
| `InvoiceIssued` / `InvoicePaid` / `InvoiceVoided` / `InvoiceOverdue` | Invoice lifecycle. |
457+
| `PaymentRecorded` / `PaymentFailed` / `PaymentRefunded` | Transaction ledger — `recordPayment()` / `recordFailedPayment()` / `recordRefund()`; carry the transaction + invoice. |
437458

438459
Listen normally:
439460

docs/01-DB-Schema.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ Optional ledger of gateway settlements linked to invoices. Tahsil itself never w
358358
| `id` | BIGINT PK | |
359359
| `invoice_id` | BIGINT FK | cascadeOnDelete |
360360
| `gateway` | VARCHAR | Defaults to `manual`. Identifies the payment source (e.g. `stripe`, `paddle`, `bkash`). |
361-
| `transaction_id` | VARCHAR NULL | Gateway-supplied id, or auto-generated by `TransactionObserver::creating` via `tashil.transaction.generator` when empty (e.g. cash collected by an admin). |
361+
| `transaction_id` | VARCHAR NULL | Gateway-supplied id, or auto-generated by `TransactionObserver::creating` via `tashil.transaction.generator` when empty (e.g. cash collected by an admin). Generated ids are made unique within their `gateway`, matching the composite constraint. |
362362
| `amount`, `currency`, `status` | | |
363363
| `gateway_response`, `metadata` | JSON NULL | |
364364
| `refunded_amount`, `refunded_at`, `refund_reason` | | |

0 commit comments

Comments
 (0)