Tax Configuration
SaaS4Builders integrates with Stripe Tax for automatic tax calculation. Stripe Tax is a tax calculation service — it determines which taxes apply and how much to charge. It is not a Merchant of Record. Your SaaS application remains the seller of record, which means you are responsible for invoice generation, tax reporting, and legal compliance.
Responsibility Split
Understanding what Stripe Tax handles versus what your application must handle is critical for compliance:
What Stripe Tax Does
| Responsibility | Description |
|---|---|
| Tax jurisdiction detection | Determines which country/state/province tax rules apply based on customer location |
| Tax rate calculation | Calculates the correct rate for the jurisdiction and product type |
| VAT number validation | Validates EU VAT numbers against the VIES database |
| Reverse charge detection | Identifies B2B intra-EU transactions where reverse charge applies |
| Global coverage | Supports tax calculation for 100+ countries |
What You Must Handle
| Responsibility | Description |
|---|---|
| Invoice generation | Creating, storing, and serving invoice documents (Stripe handles this in stripe_managed mode) |
| Invoice archiving | Legal retention of invoices (configurable via INVOICE_RETENTION_YEARS, default: 10 years) |
| Tax reporting | Filing VAT returns, sales tax reports, and other tax declarations |
| Legal mentions | Adding required legal text to invoices (e.g., reverse charge mention, VAT exemption text) |
| Tax registration | Registering for VAT/GST in relevant jurisdictions |
Enabling Stripe Tax
Stripe Tax is an opt-in feature controlled by an environment variable:
STRIPE_TAX_ENABLED=true
This maps to the billing configuration:
'tax' => [
'provider' => env('TAX_PROVIDER', 'stripe_tax'),
'stripe_tax' => [
'enabled' => env('STRIPE_TAX_ENABLED', false),
],
],
Setup Steps
- Enable Stripe Tax in your Stripe Dashboard (Settings > Tax)
- Configure your tax registration — tell Stripe where you are registered to collect tax
- Set product tax codes — assign Stripe tax codes to your products (e.g.,
txcd_10000000for software) - Set the environment variable —
STRIPE_TAX_ENABLED=truein your.env
When enabled, tax calculations are automatically applied to Checkout Sessions and invoices. When disabled, no tax is calculated and all tax amounts are zero.
The TaxProvider Abstraction
Tax calculation is abstracted behind the TaxProviderInterface, allowing you to swap providers without changing business logic:
interface TaxProviderInterface
{
/**
* Calculate tax for a given amount and context.
* Tax is calculated in the same currency as the amount.
*/
public function calculate(Money $amount, TaxContext $context): TaxCalculation;
/**
* Validate a VAT number with the tax authority.
*/
public function validateVatNumber(string $vatNumber, string $countryCode): bool;
/**
* Determine if reverse charge applies for B2B transactions.
*/
public function isReverseCharge(TaxContext $context): bool;
/**
* Get the standard tax rate for a jurisdiction.
*
* @param string $countryCode ISO 3166-1 alpha-2 country code
* @param string|null $region State/province for US, CA, etc.
* @return float Tax rate as decimal (e.g., 0.20 for 20%)
*/
public function getTaxRate(string $countryCode, ?string $region = null): float;
}
The default implementation is StripeTaxProvider (backend/app/Infrastructure/Billing/Providers/Stripe/StripeTaxProvider.php), bound in the BillingServiceProvider.
Tax Context and Calculation
Tax calculations require a context describing the customer and transaction:
TaxContext DTO
final readonly class TaxContext
{
public function __construct(
public string $customerCountry, // ISO 3166-1 alpha-2 (e.g., 'FR', 'US')
public ?string $customerPostalCode = null,
public ?string $customerState = null, // For US, CA, AU, etc.
public ?string $vatNumber = null, // EU VAT number for B2B
public ?string $taxCode = null, // Stripe product tax code
public string $reference = 'subscription',
) {}
}
TaxCalculation DTO
The calculation result contains the full tax breakdown:
final readonly class TaxCalculation
{
public function __construct(
public Money $taxableAmount, // Amount before tax
public Money $taxAmount, // Calculated tax
public Money $totalWithTax, // Amount + tax
public float $rate, // Rate as decimal (0.20 = 20%)
public string $jurisdiction, // Tax jurisdiction code
public string $taxType, // e.g., 'vat', 'sales_tax', 'gst'
public bool $isReverseCharge, // true for B2B intra-EU
public ?string $providerCalculationId = null, // Stripe calculation ID
) {}
}
All money amounts in the calculation use the same currency — cross-currency tax calculations are not supported. See Currency Rules.
VAT Number Validation
The StripeTaxProvider validates EU VAT number formats locally before calling Stripe's validation API. This prevents unnecessary API calls for obviously invalid numbers.
Supported Countries
VAT format validation covers all 27 EU member states:
| Country | Code | VAT Pattern Example |
|---|---|---|
| Austria | AT | ATU12345678 |
| Belgium | BE | BE0123456789 |
| France | FR | FR12345678901 |
| Germany | DE | DE123456789 |
| Italy | IT | IT12345678901 |
| Netherlands | NL | NL123456789B01 |
| Spain | ES | ESA12345678 |
| ... | ... | (all 27 EU countries) |
Tax ID Type Mapping
When sending tax IDs to Stripe, the provider maps country codes to Stripe's tax ID types:
| Region | Tax ID Type |
|---|---|
| EU countries | eu_vat |
| United Kingdom | gb_vat |
| Switzerland | ch_vat |
| Norway | no_vat |
| Australia | au_abn |
| New Zealand | nz_gst |
| Canada | ca_bn |
| United States | us_ein |
Reverse Charge Detection
For B2B transactions within the EU, the provider automatically detects reverse charge eligibility:
- The customer must have a valid EU VAT number
- The customer must be in a different EU country than the seller
- When reverse charge applies, the tax rate is 0% and the invoice must include a reverse charge mention
// Check reverse charge for a B2B transaction
$context = new TaxContext(
customerCountry: 'DE',
vatNumber: 'DE123456789',
);
$isReverseCharge = $taxProvider->isReverseCharge($context);
// true if your company is registered in a different EU country (e.g., FR)
Tax Records on Invoices
When invoices are synced from Stripe via webhooks, tax breakdown data is mapped to local TaxRecord entries using the TaxBreakdownMapper:
final class TaxBreakdownMapper
{
/**
* Map Stripe invoice tax breakdown to TaxRecordDraft array.
*
* @return TaxRecordDraft[]
*/
public function map(StripeObject $stripeInvoice): array
}
Each TaxRecordDraft captures:
final readonly class TaxRecordDraft
{
public function __construct(
public string $taxType, // 'vat', 'sales_tax', 'gst'
public string $jurisdiction, // Country/region code
public float $rate, // Decimal rate (0.200000)
public int $taxableAmountCents, // Taxable base in minor units
public int $taxAmountCents, // Tax amount in minor units
public ?string $stripeCalculationId = null,
) {}
}
Tax records are stored alongside invoice data, providing a complete audit trail of how taxes were calculated for each invoice.
Tax Rate Lookup
The getTaxRate() method provides indicative standard tax rates for any jurisdiction. Results are cached for 24 hours:
// Get the standard VAT rate for France
$rate = $taxProvider->getTaxRate('FR');
// 0.20 (20%)
// Get the sales tax rate for California
$rate = $taxProvider->getTaxRate('US', 'CA');
// Rate varies by locality
Company Billing Information
Your company's billing details are configured via environment variables and used on invoices:
BILLING_COMPANY_NAME=Your Company
BILLING_COMPANY_ADDRESS=123 Main Street
BILLING_COMPANY_CITY=Paris
BILLING_COMPANY_POSTAL_CODE=75001
BILLING_COMPANY_COUNTRY=FR
BILLING_COMPANY_VAT_NUMBER=FR12345678901
These values are read from config/docs/billing.php:
'company' => [
'name' => env('BILLING_COMPANY_NAME', 'Your Company'),
'address' => env('BILLING_COMPANY_ADDRESS', ''),
'city' => env('BILLING_COMPANY_CITY', ''),
'postal_code' => env('BILLING_COMPANY_POSTAL_CODE', ''),
'country' => env('BILLING_COMPANY_COUNTRY', 'FR'),
'vat_number' => env('BILLING_COMPANY_VAT_NUMBER', ''),
],
The company country is also used by the StripeTaxProvider to determine whether a transaction is domestic or cross-border (relevant for EU reverse charge).
Invoice Legal Retention
Invoice retention is configurable for compliance with local regulations:
'invoices' => [
'retention_years' => env('INVOICE_RETENTION_YEARS', 10),
'storage_disk' => env('INVOICE_STORAGE_DISK', 'local'),
'legal_mentions' => [
'default' => env('INVOICE_LEGAL_MENTION', ''),
],
],
Common retention requirements by jurisdiction:
| Country | Minimum Retention |
|---|---|
| France | 10 years |
| Germany | 10 years |
| United Kingdom | 6 years |
| United States | 3-7 years (varies by state) |
| Canada | 6 years |
Extending the Tax Provider
To implement a custom tax provider (e.g., for a different tax calculation service), create a class implementing TaxProviderInterface and bind it in a service provider:
// In a service provider
$this->app->bind(
TaxProviderInterface::class,
MyCustomTaxProvider::class
);
Your implementation must handle all four methods: calculate(), validateVatNumber(), isReverseCharge(), and getTaxRate(). The rest of the billing system depends only on the interface, so no other changes are needed.
V1 Limitations
| Limitation | Rationale |
|---|---|
| No dynamic tax overrides | Tax rates are fully determined by Stripe Tax. Manual rate overrides are not supported. |
| No manual tax exemptions | Tax exemptions must be configured in Stripe Dashboard, not via the API. |
| Stripe Tax only | The TaxProviderInterface exists for extensibility, but only StripeTaxProvider is shipped. |
What's Next
- Webhooks — How webhook events sync tax data from Stripe to your application
- Invoices — Where tax records appear on invoice data
- Currency Rules — How tax amounts follow the same currency as the invoice
Invoices
Invoice management in SaaS4Builders: Stripe as invoicing authority, invoice sync, status tracking, PDF download, and the useInvoices composable.
Webhooks
Stripe webhook architecture in SaaS4Builders: signature verification, idempotency, event dispatching, handled events, and how to add new handlers.