Stripeaccounting/en
StripeAccounting is a Dolibarr module for Stripe accounting reconciliation.
It reads the Stripe API (or a CSV export), identifies the movements the official Stripe module does not post — fees, refunds, disputes, payouts — and creates the missing accounting entries.
- Compatibility — Dolibarr 17+, PHP 7.2 → 8.5
- Depends on — Stripe, Bank and Double-entry Accounting modules
- See also — Installation and user guide
Sommaire
- 1 1. Design principles
- 2 2. Requirements
- 3 3. File tree
- 4 4. Synchronisation and idempotency
- 5 5. Accounting entries
- 6 6. CSV import
- 7 7. Configuration
- 8 8. User interface
- 9 9. Scheduled job
- 10 10. Logging and permissions
- 11 11. Database schema
- 12 12. Troubleshooting
- 13 13. Pitfalls encountered
- 14 14. Known limitations
1. Design principles
Four rules govern the whole module.
| Principle | What it means |
|---|---|
| Complementary | The module creates no payment, no invoice, no webhook. It reads Stripe and completes the books, nothing more. |
| Idempotent | Each Stripe object is processed once only, no matter how many times the sync is re-run. |
| Never guess | If an operation cannot be matched with certainty, nothing is posted: the row goes to error for manual review.
|
| Configuration is blocking | As long as the minimum configuration is incomplete, no sync runs at all (no button, no cron, no import). |
2. Requirements
Required Dolibarr modules
| Module | What it provides |
|---|---|
Stripe (modStripe) |
API key, test/live mode, Stripe financial account |
Bank (modBanque) |
llx_bank and llx_bank_account tables
|
Double-entry accounting (modAccounting) |
llx_accounting_bookkeeping table
|
Mandatory Dolibarr setting
ACCOUNTING_ACCOUNT_TRANSFER_CASH must be set under Accounting → Setup → Miscellaneous accounts. Without it, the internal transfer generated by payouts cannot be transferred to accounting (a Dolibarr limitation, not a module one).
Notes
- The Stripe PHP SDK is not re-vendored: the module reuses the core copy (
includes/stripe/stripe-php/). - The code deliberately avoids any PHP syntax newer than 7.2 (no
match,?->, enums, typed properties,str_contains…) so it runs across the whole 7.2 → 8.5 range.
3. File tree
custom/stripeaccounting/
├── core/modules/modStripeAccounting.class.php Module descriptor (permissions, menus, cron, constants)
├── class/
│ ├── StripeApi.class.php Stripe SDK wrapper (read-only)
│ ├── StripeRepository.class.php SQL access + idempotency
│ ├── StripeLogger.class.php Logging
│ ├── StripeAccounting.class.php Accounting entry generation
│ ├── StripePayout.class.php Payout reconciliation
│ ├── StripeSync.class.php Orchestrator (cron entry point)
│ ├── StripeCsvImporter.class.php CSV import
│ └── actions_stripeaccounting.class.php 'bankcard' hook
├── admin/setup.php Configuration
├── import.php CSV import
├── dashboard.php Dashboard
├── transaction_list.php Transaction list
├── cron/stripe_sync.php CLI entry point
├── lib/stripeaccounting.lib.php
├── sql/
├── langs/{fr_FR,en_US,es_ES,it_IT,de_DE}/
└── css/
Dependency direction
- pages and hook →
StripeSync→ (StripeApi,StripeRepository,StripeAccounting,StripePayout) →StripeLogger
| Class | Role |
|---|---|
StripeApi |
Queries the Stripe SDK (balance transactions, charges, refunds, disputes, payouts) with automatic pagination. No database access, no accounting entries. |
StripeRepository |
All SQL for the module's two tables. upsertTransaction() is the idempotency anchor.
|
StripeLogger |
Writes to llx_stripeaccounting_log and mirrors to dol_syslog().
|
StripeAccounting |
Builds accounting entries (refund, dispute, reverse-charge VAT) or bank movements (fees). Exposes checkConfig().
|
StripePayout |
Creates the internal bank transfer between the Stripe account and the real bank account. |
StripeSync |
Orchestrator: one method per flow, plus reconcileStripeAccount() which chains them all.
|
StripeCsvImporter |
Feeds the same pipeline from a Stripe CSV export. |
ActionsStripeAccounting |
bankcard hook: dashboard link on the Stripe account record.
|
4. Synchronisation and idempotency
Every flow (syncFees, syncRefunds, syncDisputes, syncPayouts, syncTransactions) always follows the same steps:
- Check the configuration — if incomplete:
ERRORlog, immediate stop, no API call at all. - List Stripe objects over a rolling window of 35 days.
- Filter — an object is skipped only if it already exists and its status is
reconciledorignored. A row inpendingorerroris retried on this run. - Record it as
pending, then generate the accounting entry or bank movement. - Conclude — success: status
reconciled. Failure: statuserrorwith the message, and the batch carries on with the next object.
Automatic retry — a row in error becomes eligible again on the next run. Once the cause is fixed (a fiscal year created late, for instance), no manual action is needed.
Why duplicates are impossible
- Unique database key on
(stripe_object_id, entity)— protects even against two concurrent cron executions. - Explicit check before processing — avoids even attempting to post an entry for an already-finalised object.
Special cases: adjustment and payment_failure_refund
These are balance corrections Stripe makes on its own (reversal after a dispute is resolved, automatic refund of a payment that ultimately failed). The module records them — otherwise they would stay invisible — but never posts them automatically: the sign of the amount and the underlying cause vary too much. They are created directly in error, with an explicit message, to be handled manually.
5. Accounting entries
All lines of a single Stripe event share the same doc_type='stripeaccounting', fk_doc and doc_ref triplet: Dolibarr automatically groups them under one piece number, so the module needs no numbering scheme of its own.
Overview
| Event | What the module does | Resulting entry |
|---|---|---|
Stripe fee (VAT mode none or autoliquidation) |
PaymentVarious bank movement |
627xxx / 517xxx entry generated later by Dolibarr |
Stripe fee (autoliquidation mode) |
Plus: VAT pair posted immediately | Debit deductible VAT, credit reverse-charge VAT (net impact nil) |
Stripe fee (french mode) |
Full direct entry | Debit fee account excl. VAT + deductible VAT, credit Stripe account incl. VAT |
| Subscription / service fees (Radar, Billing, Connect…) | PaymentVarious bank movement |
Entry generated later by Dolibarr, on the Stripe supplier account |
| Payout | Internal bank transfer between the two accounts | Entry generated later by Dolibarr |
| Refund (invoice found) | Direct entry | Debit customer account 411xxx, credit Stripe account 517xxx |
| Refund (invoice not found) | Nothing | Status error, handle manually
|
| Dispute / chargeback | Direct entry | Debit dispute loss 658xxx, credit Stripe account 517xxx (+ fee account if fees apply) |
Why fees and payouts produce no direct entry
The historical problem: when a Stripe-paid invoice is transferred to accounting through Dolibarr's standard workflow, the payment is posted at its gross amount — fees are ignored at that stage.
If the module posted its own entry and its own bank line on top of that, this line would stay invisible to bankjournal.php — which only recognises lines attached to doc_type='bank'. An administrator transferring the bank account to accounting "normally" would then create a second entry for the very same movement.
The chosen solution: for fees, subscription fees and payouts, the module now creates only the real bank movement, using Dolibarr's native mechanisms. The native "Transfer to accounting" button then produces the entry, exactly as for any other bank movement. In those three cases, the module no longer touches llx_accounting_bookkeeping at all.
| Case | Native mechanism used |
|---|---|
| Fees and subscription fees | PaymentVarious (bank movement with an explicit counterpart account, no third party or invoice), carrying its own accountancy_code, which bankjournal.php reads directly.
|
| Payouts | Dolibarr internal transfer: Account::addline() on both sides, plus add_url_line(…, 'banktransfert'). The counterpart is posted to ACCOUNTING_ACCOUNT_TRANSFER_CASH.
|
In both cases the status becomes reconciled — the module's job is done — but piece_num stays empty: the entry does not exist yet, only fk_bank is set. After the manual transfer, it can be found with doc_type='bank' and fk_doc equal to the row's fk_bank.
Worth knowing — the payout entry only balances to zero on the transfer account once both bank accounts (Stripe and the real bank) have been transferred to accounting, not just one of them.
⚠ STRIPE_AUTO_RECORD_PAYOUT must stay disabled
Once this module handles payouts, untick "Enable automatic recording of Stripe payments" in stripe/admin/stripe.php — despite its wording, that checkbox only concerns payouts.
Otherwise the official module's webhook and this module would each create their own bank movement for the same payout, unaware of each other.
The core webhook keeps no trace of the payout id it has processed: should Stripe deliver the event twice (delivery is guaranteed "at least once"), nothing stops it from duplicating on its own either. This module has real idempotency and automatically catches up any missed payout within 35 days.
On activation, modStripeAccounting::init() disables the stripe/payout.php menu entry: that page queries the API live without recording anything, and having it alongside this module would only create confusion about which one is authoritative for payouts. remove() restores the original condition. This is the only core table the module modifies — deliberate, and documented here for that reason.
Per-transaction fees or subscription fees?
Stripe bills two distinct things under the generic word "fees":
| Per-transaction fees | Subscription / service fees | |
|---|---|---|
| Example | Commission on each payment | Radar, Billing, Connect |
| Where to find it | The .fee field of a charge balance transaction |
A dedicated balance transaction, type stripe_fee or application_fee (its .fee is always 0, the amount sits in .amount)
|
| Account used | The configured fee account (627xxx) | The supplier accounting code of the configured Stripe third party |
| Type in database | fee |
subscription_fee
|
Limitation of the french VAT mode
This mode has to split the gross amount into net + VAT, something PaymentVarious cannot express. It therefore keeps the previous behaviour: a full direct entry, with no bank movement created. No duplicate risk (precisely because no bank line is produced), but the llx_bank balance of the Stripe account will never reflect the fees while this mode is active.
6. CSV import
The import covers the case of a Stripe account different from the one configured in the official module — another PrestaShop shop with its own merchant account, for example. The API has no access to it: its contents are exported to CSV from the Stripe dashboard, then imported.
Starting assumption
The invoices and payments of that shop are already posted in Dolibarr through some other process, and the collection (debit 517, credit 411) is already booked. Only the fees, refunds and payouts are missing — which is exactly what the import handles.
Interface: a single field
import.php now exposes only one upload field, General history, wired to importGeneralHistory(). That file alone covers every movement type.
importPayments() and importPayouts() are still implemented and functional, but no longer reachable from the interface; they are documented below for reference.
Like the "Sync now" button, the import is blocked — warning banner, disabled button, server-side guard — while the configuration is incomplete.
The three methods
| Method | Stripe export read | Processing |
|---|---|---|
importGeneralHistory()(the only one exposed) |
Balance history | Dispatch on the Type column, see table below
|
importPayments() |
Payments | Fee → fee movement. Amount refunded → refund row, always in error.
|
importPayouts() |
Payouts | Exactly the same processing as a payout coming from the API |
General history dispatch, by Type column:
| Stripe type | Processing |
|---|---|
payout |
Internal bank transfer, as via the API |
charge / payment with a fee |
Fee movement |
stripe_fee / application_fee |
Subscription fee, on the Stripe supplier account |
refund |
Recorded, always in error
|
payout_minimum_balance_hold / …_release |
Ignored — Stripe rolling reserve, no real cash movement, nothing to post |
| any other type | Recorded as type='other', status error, raw Stripe type kept in the description. No guessed entry.
|
Columns are matched by name: their order does not matter, and extra unused columns are ignored.
Import idempotency
The unique key protects against any double import: re-importing the same CSV, or one overlapping a previous import, creates no duplicate.
An important detail: in the general history, payout and charge/payment rows are indexed by their Source column (po_…, ch_…), not by the row's own id (txn_…) — the very same key the dedicated imports use. Importing the general history on top of the specific exports therefore never posts the same entry twice, in any order.
Since Stripe identifiers are unique platform-wide, mixing rows from the API account and from several CSV-imported accounts in the same table carries no collision risk.
Practical details
- Source label — a free-text field entered at import time (e.g. "DRM shop") is stored on every created row and serves as a column and filter in the list, to tell several shops apart side by side.
- Amount format — Stripe CSVs use the French decimal comma, converted through
price2num(). Dates marked(UTC)are interpreted as such.
7. Configuration
The API key and test/live mode are not re-entered here: they are read, read-only, from the official Stripe module.
| Field | Constant | Purpose |
|---|---|---|
| Stripe financial account | STRIPE_BANK_ACCOUNT_FOR_PAYMENTS (official module constant) |
Bank account selector. No local copy, hence no risk of divergence. |
| Main bank account | STRIPE_BANK_ACCOUNT_FOR_BANKTRANSFERS (official module constant) |
Same selector. See the box below. |
| Fee account | STRIPEACCOUNTING_COMPTE_FRAIS |
Account number, e.g. 627xxx |
| Dispute account | STRIPEACCOUNTING_COMPTE_LITIGES |
Account number, e.g. 658xxx. Required — used as soon as a dispute occurs, whatever the VAT mode. |
| Stripe supplier third party | STRIPEACCOUNTING_FOURNISSEUR_STRIPE_ID |
A third party flagged as supplier; its supplier accounting code is what gets used. Optional, unless subscription fees exist. |
| VAT mode | STRIPEACCOUNTING_VAT_MODE |
none, french or autoliquidation
|
| Deductible VAT account (intra-EU) | STRIPEACCOUNTING_COMPTE_TVA_DEDUCTIBLE |
Required when the VAT mode is not none. Stripe always invoices from a non-French EU entity, so the deductible VAT is intra-EU in every case.
|
| VAT due account (intra-EU) | STRIPEACCOUNTING_COMPTE_TVA_AUTOLIQUIDATION |
Required in autoliquidation mode only — never used in french mode.
|
| VAT rate | STRIPEACCOUNTING_VAT_RATE |
Percentage, 20 by default |
| Sync frequency | STRIPEACCOUNTING_SYNC_UNITFREQUENCY |
Applied to the scheduled job (in seconds) |
Why redefine the main bank account here?
The official Stripe module's setup page only renders that field when STRIPE_AUTO_RECORD_PAYOUT is enabled — and its own save handler resets the value to 0 on every save while the field is not rendered. Yet that setting must stay disabled once this module handles payouts. The field is therefore re-exposed here, writing the core constant directly, so the value stays configurable and stable.
Both account selectors only write their constant when a real account was submitted: saving the page for some unrelated field never overwrites a correct value.
Configuration check
StripeAccounting::checkConfig() returns the list of missing constants. It is called both by the setup page (information banner) and by every sync entry point (the real guard that blocks all processing).
Pitfall with accounting account selectors
The four fields using FormAccounting::select_account() (fees, disputes, both VAT accounts) have a "nothing selected" option whose value is -1 — a non-empty string, therefore truthy in PHP. A field left blank and saved literally stores -1.
StripeAccounting::getAccountConst() normalises that value to an empty string on read, everywhere these constants are read. No entry can therefore land on the non-existent account -1.
8. User interface
Dashboard
Stripe balance (live API call), number of unreconciled transactions, total fees for the month, pending payouts, and a Sync now button — disabled, with a banner listing the missing constants, while the configuration is incomplete.
Transaction list
A paginated, sortable, filterable list built with the same mechanism as native Dolibarr lists: the icon at the top right of the table lets each user pick their columns, and the preference is stored per user.
| Column | Content |
|---|---|
| Amount, Fee, Total | The fee popup shows the VAT on the fee whenever a reverse-charge pair or French VAT was posted |
| Type, Description | Popup with the Stripe ID, the event or source ID, and the full untruncated description |
| Third party | The customer resolved through the invoice, or through the source charge for its related fee, or the configured Stripe supplier for a subscription fee |
| Entry | A "Piece no. X" link to the accounting entry, and/or a link to the related bank movement — see the caveat below |
| Source, Status | The error badge opens a popup with the full error message: no need to query the database to understand a failure
|
Reading the "Entry" column — on a fee, subscription_fee or payout row, the link points to the bank movement, not to an accounting piece. The module's piece_num is never the 627/517 entry for the fee: it is only the reverse-charge VAT pair. The fee entry itself is generated later, by Dolibarr's native button, and is never reported back onto this row.
The page is purely informational: no manual posting is offered there. The module posts automatically, and the actual transfer to accounting for fees and payouts happens through the native "Transfer to accounting" button.
charge and fee rows
Each payment produces two rows in the database:
- a
chargerow, always in statusignored, fee 0, never processed — present solely for idempotency; - a
feerow for the same payment, linked to the first one.
Both appear separately in the list, but the fee row also carries the link to the payment and the invoice, resolved through a join.
Test mode banner
While live mode is off, the dashboard, the setup page and the transaction list all display the same warning as the official Stripe module. It is impossible to forget you are working in test mode.
Menus
Everything sits under Banks and cash → Stripe account (the official module's menu), flat, as three entries at the same level:
- Stripe dashboard
- Stripe transactions
- CSV import (administrators only)
A dashboard link is also added automatically on the bank account record configured as the Stripe account.
9. Scheduled job
There are two ways to run the sync, both idempotent and safe to re-run. In both cases the code executed is strictly the same: no behavioural difference between the two triggers.
⚠ Neither of them starts on its own.
Activating the module creates the StripeAccountingSync job, but disabled by default. And even once enabled, the Dolibarr scheduler is triggered by nothing until a system crontab calls it. Without both steps, the sync only happens when "Sync now" is clicked manually.
Step 1 — enable the job
Under Home → Setup → Modules → Scheduled jobs. Since llx_cronjob is a core table, this setting is made through the interface only, never by direct SQL from the module.
Step 2 — a system trigger
Pick either option.
Option A — Dolibarr scheduler (recommended when several Dolibarr scheduled jobs coexist). Runs every due job, not just this one. Requires step 1.
*/15 * * * * curl "https://your-dolibarr/public/cron/cron_run_jobs_by_url.php?securitykey=YOUR_KEY&userlogin=YOUR_LOGIN"The security key is generated on the scheduled jobs page, "Security of job execution by URL" button.
Option B — the module's CLI script. Standalone, refuses to run from a browser, and independent of whether the Dolibarr job is enabled.
*/15 * * * * php /path/to/htdocs/custom/stripeaccounting/cron/stripe_sync.php >> /var/log/stripeaccounting.log 2>&110. Logging and permissions
Logging
Every API call, generated entry, warning or error is traced in llx_stripeaccounting_log, readable in the database or through the standard Dolibarr log file. Levels: DEBUG < INFO < WARNING < ERROR. The context column holds the structured detail as JSON (API payload, computed amounts…).
Permissions
| Right | Controls |
|---|---|
read |
Access to the dashboard and the transaction list |
write |
Declared for configuration, but not checked in the code today |
reconcile |
Manual sync triggering |
In practice, admin/setup.php and import.php only check Dolibarr administrator status, not the write right: only administrators can configure the module or import a CSV, whatever is ticked in the module permissions.
11. Database schema
llx_stripeaccounting_transaction
One row per processed Stripe object — charge, fee, refund, dispute or payout — all in the same table, distinguished by the type column.
| Column | Type | Role |
|---|---|---|
rowid |
int AI | Primary key |
entity |
int | Dolibarr multi-company |
stripe_object_id |
varchar(80) | Stripe identifier — idempotency anchor |
stripe_event_id |
varchar(80) | Event identifier, informational only |
type |
varchar(20) | charge, fee, subscription_fee, refund, dispute, payout, other
|
stripe_source_id |
varchar(80) | Source Stripe object (e.g. the charge behind a refund) |
source_label |
varchar(80) | Free-text label entered at CSV import; empty for API rows |
description |
varchar(255) | Stripe label |
amount, fee, net |
decimal(24,8) | Amounts already converted from Stripe cents |
currency |
varchar(3) | ISO currency code |
date_created, date_available_on |
datetime | Stripe-side dates |
fk_facture, fk_paiement |
int | Resolved Dolibarr invoice and payment, if found |
fk_bank |
int | Generated llx_bank line (empty for a fee in french VAT mode)
|
piece_num |
varchar(20) | Generated accounting piece number, where there is one |
status |
varchar(16) | pending, reconciled, error, ignored
|
error_message |
text | Detail on failure |
date_creation, date_processed |
datetime | Technical timestamps |
fk_user_creat |
int | User who triggered the processing |
Unique key on (stripe_object_id, entity) — this is what guarantees the same Stripe object can never be inserted twice, even with concurrent executions.
llx_stripeaccounting_log
| Column | Role |
|---|---|
level |
DEBUG, INFO, WARNING, ERROR
|
message |
Short message |
context |
Structured context as JSON |
fk_stripeaccounting_transaction |
Related transaction, where applicable |
datec |
Timestamp |
12. Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| The "Sync now" button is disabled | Minimum configuration incomplete | Fill in the fields listed in the setup page banner |
A transaction stays in error |
Entry rejected by Dolibarr (non-existent account, closed period), or refund not matched | Read error_message on the row, then the detail in the log table
|
| The Stripe balance is not displayed | API key missing or invalid in the official Stripe module | Check stripe/admin/stripe.php
|
| A refund is never reconciled | The original payment was not found through ext_payment_id |
Check that the Stripe payment was recorded by the official module with that identifier |
A subscription_fee stays in error |
Stripe supplier third party not set, or has no supplier accounting code | Fill the field in setup, and complete the third party record |
A CSV-imported refund stays in error |
Normal and expected — that account is not linked to ext_payment_id |
Handle the invoice and refund manually in accounting |
13. Pitfalls encountered
Development notes, kept so nobody falls into them again.
-
PaymentVarious::create()only updatesfk_bankin the database, never on the PHP object. Afetch()aftercreate()is required to get the real value. -
select_comptes()prints its own HTML and returns the number of accounts. Call it as a bare statement: wrapping it inprintwould display that number right after the select tag. -
master.inc.php, notmain.inc.phpfor the CLI script. The latter expects an HTTP session and, in pure CLI, exits silently — no output, no log, return code 0 — inside its redirect to the login page. This gives the misleading impression that the script "does nothing" rather than a real error. - Core libraries —
master.inc.phpdoes not load everythingmain.inc.phploads for the web (e.g.core/lib/date.lib.php, required byBookKeeping). Include them explicitly if the CLI script evolves. - The "nothing selected" option is
-1in accounting account selectors — see the Configuration section.
14. Known limitations
- Zero-decimal currencies (JPY, KRW…) are not handled: amounts are divided by 100 assuming two decimals. Intended use: EUR.
- The 35-day window is not configurable. A sync interrupted for longer must be caught up manually.
- Refund matching — relies on
ext_payment_idandext_payment_siteinllx_paiement, filled in by the standard Stripe payment flow. A payment recorded any other way cannot be matched automatically. Matching goes through the PaymentIntent id, never the charge id: the core Stripe module never stores the latter (it stores either the raw PaymentIntent, or a composite value, depending on the payment path taken). - CSV import assumes French notation: an export with a decimal point would not be parsed correctly.
- The "Transfer" column of the Payments CSV is stored for information only: it is not used to check that the sum of payments matches the imported payout amount.
- CSV-imported refunds always land in
error— intended behaviour, not a bug. -
frenchVAT mode — the Stripe account's bank balance does not reflect the fees, see section 5.