You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CLAUDE.md
+26-8Lines changed: 26 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -4,7 +4,7 @@ AI working guide for `foysal50x/tashil`. Read before touching code.
4
4
5
5
## What this package is
6
6
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.
8
8
9
9
**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.
10
10
@@ -178,6 +178,7 @@ Adding a feature ⇒ add a Feature test. Fixing a bug ⇒ add a regression test
178
178
|---|---|
179
179
| New subscription state transition |`SubscriptionService` method + `EventStore::append` + event class + state machine doc |
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.
200
204
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()`:
-`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`.
- 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.)
259
273
- 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.)
261
275
- Coupon / discount engine.
262
276
- Wallet / balance / account ledger — Metered features delegate to host via `MeteredBilling`. Don't introduce balance tables here.
@@ -374,22 +374,42 @@ See [docs/03-Trial-System.md](docs/03-Trial-System.md).
374
374
375
375
---
376
376
377
-
## Invoices
377
+
## Invoices & transactions
378
378
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.
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
`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`.
389
409
390
410
Invoice numbers are generated by `tashil.invoice.generator` — default `InvoiceNumberGenerator` parses the format string `#-YYMMDD-NNNNNN` (e.g. `INV-260522-849021`).
391
411
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).
393
413
394
414
---
395
415
@@ -434,6 +454,7 @@ All events dispatch after `DB::afterCommit()` so listeners never see torn state
Copy file name to clipboardExpand all lines: docs/01-DB-Schema.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -358,7 +358,7 @@ Optional ledger of gateway settlements linked to invoices. Tahsil itself never w
358
358
|`id`| BIGINT PK ||
359
359
|`invoice_id`| BIGINT FK | cascadeOnDelete |
360
360
|`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. |
0 commit comments