Company B2B subscriptions, billing and access

📄 General
← Back to Documentation
# Company B2B subscriptions, billing and access ## Implementation status The application now contains myPOS recurring-payment collection, cycle renewal, daily proration, overdue/grace/dunning processing, queued invoice email/PDF delivery, financial snapshots and database-backed invoice numbering. **Live billing and collection are disabled by default.** The targeted local suite passes 34 tests with 110 assertions, including a connected subscription-to-renewal-to-email-to-collection journey using fake external services. Local integration tests are not a certification of live myPOS merchant capabilities, inbox delivery, multi-process MariaDB concurrency or exhaustive browser/API coverage. Remaining acceptance work is listed below. ## Catalog and administration Run `php artisan db:seed --class=SubscriptionPlanSeeder` for initial installation. Re-running updates catalog prices, so review before doing so. | Plan | Monthly EUR | Yearly EUR | Included modules | |---|---:|---:|---| | Starter | 49 | 490 | None | | Professional | 99 | 990 | Advanced Analytics | | Enterprise | 199 | 1,990 | Advanced Analytics, API Access | | Optional module | Slug | Monthly EUR | Yearly EUR | |---|---|---:|---:| | Fiscal Device | fiscal-device | 19 | 190 | | Channex | channex | 39 | 390 | | Advanced Analytics | advanced-analytics | 15 | 150 | | API Access | api-access | 25 | 250 | Filament Subscription Plans and Subscription Modules manage the database-backed public pricing catalog. Company owners/admins/managers use **Subscriptions > Modules & billing**, outside Filament. Optional selections belong to the company subscription; included modules cannot be disabled through that screen. Billing management first verifies access to the selected company. Company managers have full management permissions for accessible companies, including memberships with the legacy default `staff` value. Other users require an `owner`, `admin`, or `manager` membership. Platform administration follows the existing `User::isAdmin()` policy. Company users cannot send platform invoices, mark their own invoices paid or cancel them. Subscription feature and module requirements still apply to company managers. The separately configured `companies.b2b_commission_rate` is controlled by the platform administrator. It is not inherited from the plan's legacy commission field. ## Billing policy and proration The implemented commercial policy is **billing in arrears**. A new subscription activates immediately without upfront collection. - Monthly/yearly cycles keep their original start-date anchor. January 31 renews on February 28 and then March 31. - Plan/module changes take effect on their calendar day in the application timezone. The last selection that day determines that day's price. - `b2b_subscription_terms` records component prices and currency. Each elapsed segment is prorated by calendar days and rounded to cents, half-up. - A EUR 28 module used for 7 days in a 28-day cycle adds EUR 7 before VAT. - Included modules have zero additional price. Enabled paid modules retain contracted prices until removed and re-added. Selecting a plan uses its current catalog price. - Cancellation takes effect at cycle end; the final elapsed cycle is still invoiced. There is no automatic refund or immediate-cancellation credit. - Currency changes are rejected. Downgrades below existing usage are rejected. - Catalog price edits do not rewrite term history. Catalog feature edits affect checks against the current plan. `SubscriptionManager` locks the company while creating or changing subscriptions and prevents duplicate active subscriptions through that service. Creating a new catalog feature alone does not implement an executable entitlement. ## Subscription and commission invoices The hourly `b2b-billing:process` command closes due subscription cycles, creates reviewable drafts and advances renewal dates. Catch-up is limited to 24 cycles per subscription per run. Renewal failures are logged without blocking other companies. The monthly `b2b-invoices:generate-monthly` command creates **commission-only** drafts for the previous calendar month, scheduled on the first at 03:15. Use `--date=YYYY-MM-DD` for another month. Only completed stays with both actual check-in and check-out qualify. At completion, `B2BStayCommissionObserver` snapshots the company rate, realized amount (falling back to booking total), commission and currency. Later saves preserve this snapshot. Historical stays without snapshots require review; they are not silently charged at today's rate. Generated invoices contain a unique billing key, period, itemized amounts, currency, seller/buyer snapshots and tax rate. Repeated generation returns the existing invoice. Generated financial snapshots cannot be changed through model updates; model-based deletion is forbidden. Numbers use a transactionally locked `b2b_invoice_sequences` row, for example `B2B-INV-2026-00000001`, plus the invoice number's database unique constraint. Normal Eloquent creation paths use this allocator. Raw SQL remains a privileged bypass. Parallel MariaDB verification and local legal numbering review remain deployment requirements. VAT comes from the **platform seller's** configured `B2B_VAT_RATE`, not the accommodation company's VAT setting. Review applicable tax treatment before launch. Zero-total subscription invoices are marked paid without collection; empty commission periods create no invoice. ## Invoice PDF and email delivery Filament B2B Invoices provides PDF download, manual send and confirmed manual-payment recording. The send action queues `SendB2BInvoice`; it no longer only changes a status. The worker renders a DomPDF invoice and sends `B2BInvoiceMail` to the billing contact. The invoice becomes sent with `sent_at` only after mail-transport acceptance. The due date is 14 days from the send attempt and matches the PDF. `b2b_invoice_deliveries` stores one row per invoice/delivery kind with pending, sending, sent or unknown status. Atomic claiming prevents duplicate jobs from sending twice. An interrupted/failed send may already have reached the mail server, so ambiguous deliveries are **not automatically resent**. Review provider logs before an administrator resets/retries them. Transport acceptance is not inbox delivery; bounce handling is not implemented. A valid billing email and seller name/address are required before queueing. Configure a real mail transport: the repository's default `MAIL_MAILER=log` only logs messages. Run both a Laravel queue worker and the scheduler. PDF rendering disables remote-resource access. ## myPOS authorization, collection and retries 1. The company manager accepts recurring-payment consent and opens a signed hosted myPOS token-only purchase form (`CardTokenRequest=1`). The application never receives card numbers or CVVs. 2. Only a signature-verified `IPCPurchaseNotify` matching the stored setup reference, merchant SID and expected method can store a token. Setup expires after one day. Browser returns do not authorize payment. 3. Tokens are encrypted using Laravel's application key in `b2b_payment_methods` and hidden from model serialization. 4. Sent invoices are collected on/after their due date using `IPCIAPurchase`. Drafts are never automatically sent or charged. 5. Every attempt has a stable order ID and persisted state. Signed successful responses must match the invoice amount and currency and include a transaction reference. 6. Timeouts/ambiguous results become unknown. Later runs reconcile the same order with `IPCGetPaymentStatus` before considering any new charge. 7. Confirmed declines retry after 1, then 3, then 7 days following the preceding decline, up to four total charge attempts. Other provider errors remain subject to reconciliation/review. 8. Revocation prevents new charges, not outstanding debt or an already in-flight charge. Reconciliation continues even after revocation. Unknown/in-flight attempts must be reconciled before recording a manual payment. This avoids treating the same debt as both bank-paid and card-paid. The merchant must be approved by myPOS for token-based recurring/in-app payments. Configure the existing SID, wallet, key index, HTTPS IPC URL and private/public key paths. See the official [recurring-payment guide](https://developers.mypos.com/sdks/php-sdk/sample-code/recurring-payments), [in-app purchase API](https://developers.mypos.com/apis/checkout-api/in-app-purchase) and [payment status API](https://developers.mypos.com/apis/checkout-api/get-payment-status). Live merchant validation has not been performed locally. ## Overdue access and recovery After a sent invoice passes its due date, it becomes overdue and the active company subscription becomes past due. Access remains for seven days from the invoice due date. Older unpaid invoices retain the earliest grace deadline. Reminders are scheduled for overdue days 1, 3 and 7. Missed runs queue only the latest applicable reminder, not all three together. Delivery-kind uniqueness makes repeat runs idempotent. After grace, operational access is denied. Billing/account recovery remains reachable. Paying the last overdue invoice restores active status, subject to subscription dates and cancellation. Suspension is not cancellation: recurring billing continues until cancelled. Trials expire at their end and are not automatically converted into paid subscriptions. ## Entitlements and quotas `SubscriptionAccess` checks company, status, dates, grace, cancellation, active plan and the required module or supported feature. `config/b2b-entitlements.php` maps route families/model writes to features. Checks protect company-admin web/API routes, the legacy company API group and company/scanner capability paths. Fiscal enqueueing, receipt processing/printing, company fiscal APIs, Channex onboarding, ARI outbox/live mappings and booking import also check access. Incoming Channex revisions can still be retained for audit rather than discarded. All matching route feature requirements are enforced, rather than stopping at the first match. For example, financial reports require both their financial and analytics entitlements. Recovery exemptions are anchored to company-admin billing/account paths; a nested operational path containing `profile` cannot bypass enforcement. Channex payload generation and previews check the module directly, including calls outside HTTP middleware. Booking, venue and membership model saves use a company-row lock for quota checks. Users count `company_user` memberships; venues count venue rows; monthly bookings count calendar-month creation, including soft-deleted records. Initial owner membership is allowed during onboarding. User creation/invitation paths create authoritative memberships. **Coverage boundary:** model events cannot intercept raw SQL, query-builder bulk writes or `saveQuietly()`. Legacy routes/jobs and external API-token semantics need an exhaustive acceptance inventory before claiming every feature and limit is enforced. API Access must not be confused with charging for every internal/mobile API request. Arbitrary legacy features JSON and pricing descriptions are not executable entitlements. ## Deployment and historical data 1. Back up and rehearse migrations on a production-like staging database. Apply the September 9 lifecycle migration after the existing subscription/module migrations. If the pre-existing `roles` table was created outside the current role migration and lacks `slug`, also apply `2026_09_09_130000_add_role_slugs_to_legacy_roles.php` before using the company role form. It backfills stable values such as `reception-manager-42` and adds a unique index. On the staging host, deploy this migration with `php artisan migrate --force`. 2. Review catalog prices, currencies, company billing contacts, memberships and commission rates. Assign the global company-manager role only to users who should manage every company they can access. 3. For each legacy subscription without term history, review its contracted prices and explicitly run: ```bash php artisan b2b-billing:initialize SUBSCRIPTION_ID --accept-current-prices ``` This starts history **today**, preserves the cycle anchor and prorates only the remaining days of the first cycle. It does not invent historical charges. Already initialized histories are rejected. Historical commission snapshots require separate reviewed handling. 4. Configure `B2B_SELLER_NAME`, `B2B_SELLER_ADDRESS`, `B2B_SELLER_VAT_NUMBER`, `B2B_SELLER_IBAN` and `B2B_VAT_RATE`. Review taxation/numbering with the platform accountant. 5. Configure myPOS, reachable HTTPS callbacks, production mail and workers. Preserve `APP_KEY`; changing it without rotation breaks token decryption. 6. Enable `B2B_BILLING_ENABLED=true` after historical-data and entitlement review. Keep `B2B_RECURRING_ENABLED=false` until merchant sandbox testing is accepted. Both default to false. 7. Verify real PDFs/mail delivery, scheduler/dunning, scoped manager access, quota races and invoice numbering under parallel MariaDB workers. Do not test against live customer cards. ## Verification and remaining acceptance Run the isolated suites without migrating the developer database: ```bash php vendor/bin/phpunit tests/Feature/B2BBillingLifecycleTest.php tests/Feature/CompanyFiscalApiTest.php tests/Unit/Channex/AriOutboxServiceTest.php tests/Unit/Channex/InitialAriSyncServiceTest.php ``` `tests/Support/render-b2b-invoice.php` generates a clearly marked local visual-QA sample without database access, email or payment requests. The sample was rendered and visually inspected. Remaining: merchant sandbox/live validation; mail inbox/bounce checks; multi-process production-database tests; full browser billing journeys; exhaustive legacy/API/background entitlement coverage; reviewed historical memberships/commissions; refund/credit-note and ambiguous-delivery operator workflows. These are not considered completed merely because local service tests pass.