Skip to main content

Payment in person

A sale at the counter is a second payment path beside the remote one. A card reader stands somewhere, the buyer presents a card, and the sale is taxed in the country the reader stands in. The package drives the reader from your server, through Stripe Terminal's server-driven integration or Mollie's point-of-sale payments, so there is no code to run on the reader or in a browser.

The path is the CardPresentPayments contract, and both drivers bind it. Ask the container before you offer a sale at the counter, because a driver without readers binds nothing:

use Pushery\Billing\Contracts\CardPresentPayments;

if (app()->bound(CardPresentPayments::class)) {
// offer the counter
}

Pairing a reader​

The two providers pair in opposite directions, so pairing is not part of CardPresentPayments. Each driver binds the contract its provider speaks.

Stripe. A reader belongs to a Stripe Terminal location, and the location's address is where every sale on the reader is placed. Create the location in the Stripe dashboard or through the API, then pair the reader with the code it shows on its screen:

use Pushery\Billing\Contracts\PairsReadersByTheirCode;

$reader = app(PairsReadersByTheirCode::class)->pairReader('purple-dog-cat', 'tml_123', label: 'Till 1');

$reader->country; // 'DE', from the location's address
$reader->canTakeASale(); // online, not busy, and standing somewhere with a country

Mollie. Mollie issues a code for a profile, and the merchant types it into the terminal. Show the code, or the QR image that comes with it, to whoever holds the terminal:

use Pushery\Billing\Contracts\IssuesReaderPairingCodes;

$code = app(IssuesReaderPairingCodes::class)->requestPairingCode('pfl_123');

$code->code; // what the merchant types into the terminal
$code->qrCode; // the same code as an image, a data URI
$code->expiresAt; // after this, request a new one

Mollie issues pairing codes in live mode only. With a test key it answers 400, "This API endpoint is only available in live mode", and the call throws Mollie's ApiException. A test profile with point of sale enabled already has a virtual terminal, so nothing needs pairing there.

The terminal then appears among the profile's terminals in the Mollie dashboard. Mollie reports no address for a terminal, so declare where each one stands before it takes a sale:

// config/billing.php
'mollie' => [
'terminal_countries' => [
'term_7MgL4wea46qkRcoTZjWEH' => 'DE', // one terminal
'pfl_QkEhN94Ba' => 'AT', // every terminal of this profile
],
],

A terminal's own entry wins over its profile's, even when it is unusable, so a mistyped country refuses the sale rather than placing it by the profile. A terminal with no usable entry takes no sale.

reader() asks the provider for the reader's current state. On Stripe, online says whether the provider can reach the reader and busy whether it is taking a payment right now. Mollie reports neither. For a Mollie terminal, online says whether it is activated, and busy whether the last sale the package put on it is still open at Mollie.

Putting a sale on a reader​

use Pushery\Billing\Enums\TaxArchetype;
use Pushery\Billing\ValueObjects\InPersonSale;
use Pushery\Billing\ValueObjects\Money;

$collection = app(CardPresentPayments::class)->collect('tmr_123', new InPersonSale(
sold: TaxArchetype::ConsumerGoods,
gross: Money::of(2_490, 'EUR'),
description: 'Coffee mug, blue',
reference: 'till-1-000417',
));

The gross is the price the buyer was shown, tax included. The description is what the receipt prints as the goods or the service it is for, so a sale cannot go up without one. The reference is yours: a second call with the same reference reaches the same payment, so a retried request does not put the sale up twice.

The sale is taxed in the country the reader stands in, before anything is created at the provider. Goods handed over at the counter are supplied there whoever buys them, so a business from another member state does not move the place with its VAT id, and a tourist pays the tax of the country the shop is in. A service keeps its own place when it is paid in person, so pass the buyer as a TaxContext where you know them. A tip names what it was paid on as soldAlongside. Taxes has the rules.

The reader then shows the gross and waits for the card. collect() does not wait with it: it returns the tax decided for the sale and the provider's reference for the payment.

A sale is refused with ReaderUnavailable, and nothing is created at the provider, when the reader is offline or not activated, when it is already taking a payment, or when it stands in no country the package knows. cancel() takes a sale off a reader before the buyer has paid. On Stripe it clears the reader. Mollie takes a terminal payment off only at the device: it never reports an open one as cancelable, so cancel() throws SaleNotCancelable with the reader and the payment, and the sale stays on the terminal until the buyer pays it, somebody presses the cancel button on the device, or it expires. Without a sale on the reader cancel() does nothing on either provider.

On Mollie, creating the payment is what puts it on the terminal. If the package then cannot keep the sale, it asks Mollie to cancel the payment before the error reaches you. Since Mollie refuses that for a terminal payment, take the sale off at the device when the reader still shows it.

When the card is presented​

The provider confirms the payment through the webhook, and only then is the sale documented. Until the card was presented nothing had been sold. The receipt states the place, the rate and the split decided when the sale went up, which the package kept in billing_in_person_sales. Nothing is decided again, so the receipt cannot state a different tax than the one the reader charged.

GrossDocument
up to and including billing.card_present.small_amount_threshold_minora simplified invoice: the seller, the date, what was sold, and the gross with its rate as one sum. Germany's limit is €250 (§ 33 UStDV)
above ita payment record, which states the same facts without claiming to be an invoice. A full invoice needs the buyer's name and address, and a buyer at the counter gives none unless they ask for one (see below)

The receipt is numbered in your invoice series (billing.invoices.number_prefix), beside the invoices the package raises for orders. Its owner is the sale, under the morph alias billing_in_person_sale: a buyer at the counter has no account the document could be filed under. On Stripe a declined card leaves the payment open on the reader, so the buyer can present another one. On Mollie a declined card ends the payment, and the sale closes; put it up again for a second card. A payment the provider cancels, or one that expires, closes the sale without a receipt.

A buyer who asks for a full invoice​

A business that buys at the counter needs its name and address on the document to deduct the tax, and any buyer may ask for more than a simplified invoice. Restate the receipt they already hold as a full invoice:

use Pushery\Billing\Invoicing\InPersonReceiptIssuer;

$invoice = app(InPersonReceiptIssuer::class)->reissueAsFullInvoice($receipt, [
'name' => 'Tischlerei Holz GmbH',
'address' => 'Werkstrasse 5',
'postcode' => '80331',
'city' => 'Munich',
'country' => 'DE',
'vat_id' => 'DE123456789',
], now()->toImmutable());

The street goes under address, the key every document reads it from; line1 is accepted as the same field. The full invoice gets its own number and names the receipt it restates, so the sale is counted once. Its tax is the receipt's, taken from the receipt's frozen columns: a VAT id does not move goods handed over at the counter. The receipt stays as it was, because an issued document is not taken back. A name and a postal address are required, and a document that is already such a restatement, or is not a receipt from the counter, is refused.

Cards only, and why​

The path takes cards and nothing else. On Stripe the payment is created for card_present alone, on Mollie as a pointofsale payment, and there is no way to settle part of a sale at the counter with a credit balance, a voucher or a gift card.

That is what keeps the package from being a cash register. Under the German Fiscal Code a system that records payments at the point of sale has a cash-register function as soon as it takes cash, and money substitutes count as cash: vouchers accepted instead of money, stored-value cards, credit balances (AEAO to § 146a, No. 1.2). A system with that function must be protected by a certified security module (§ 146a AO, KassenSichV), and the package has none. A card payment is not cash, so a counter that takes only cards needs none.

If your counter redeems vouchers or takes cash, record those sales in a certified cash register of your own and use the reader path only for sales paid entirely by card.

Mollie offers no parameter that restricts a point-of-sale payment to cards. Its terminals take vouchers only under a separate contract with a voucher brand, set up for the point of sale; if your Mollie account has one, the package cannot keep vouchers off the terminal, and that counter needs a cash register of its own.

Where this path stops​

Offline. The server sends the payment to the reader, so a reader the provider cannot reach cannot take it. There is no copy on the reader that would take the card and pass the payment on later. On Stripe the package refuses the sale rather than queueing it. On Mollie it cannot tell beforehand: an activated terminal that lost its connection takes the sale up, Mollie fails the payment when it has not reached the terminal within thirty seconds, and the webhook closes the sale. Either way its tax is decided when it goes onto the reader, and its receipt is issued when the payment is confirmed, never before.

Austria. Austrian law counts a card payment at the counter as a cash sale (§ 131b (1) no. 3 BAO), so a shop there must record it in a registered cash register (RKSV). The package is not one. It takes the payment and issues the receipt as anywhere else, and you record each of those sales in your own RKSV register as well.

Sales somebody else put up. On Mollie the package knows the sales it put on a terminal itself. A payment another system started on the same terminal is not seen, so busy does not count it. Mollie then fails the second payment because the terminal is busy, and the webhook closes that sale.

Sales that are not yours. Every sale on a reader is your own. A reader that takes payments for another seller, paid out to that seller, is not supported.