# 06. SMS

Moduł, który sprzedaje ten system. Przypomnienie na dzień przed wizytą realnie zmniejsza liczbę nieobecności, a to jedyna funkcja, za którą salon widzi zwrot w pierwszym miesiącu.

## Operator

**sms.pl**, korzystający z infrastruktury i API SerwerSMS. Dokumentacja: `dev.serwersms.pl`, adres bazowy API `https://api2.serwersms.pl/`.

Cały kontakt z operatorem przechodzi przez jedną klasę `SmsGateway` z interfejsem:

```php
interface SmsGateway {
    public function send(string $phone, string $text, array $options = []): SendResult;
    public function status(string $providerId): DeliveryStatus;
    public function balance(): float;
    public function incoming(\DateTimeInterface $since): array;
}
```

Implementacja `SmsPlGateway` siedzi w `app/Modules/Sms/Gateways/`. Zmiana operatora to napisanie drugiej implementacji i zmiana jednej linii w konfiguracji. Reszta systemu o operatorze nie wie.

## Wysyłka

**Endpoint:** `POST https://api2.serwersms.pl/messages/send_sms.json`

| Parametr | Wartość w Salonio |
|---|---|
| `username`, `password` | Z konfiguracji, nigdy w kodzie |
| `phone` | Numer w E.164 |
| `text` | Treść po podstawieniu zmiennych |
| `sender` | `salons.sms_sender`, jeśli zatwierdzony. Bez nadawcy wiadomość idzie jako ECO |
| `utf` | `true`, jeśli treść zawiera polskie znaki |
| `details` | `true`, żeby dostać identyfikator wiadomości |
| `unique_id` | `sms_messages.id`, do powiązania raportu doręczenia |
| `dlr_url` | Adres `/webhook/sms/dlr` |

Odpowiedź sukcesu:

```json
{ "success": true, "queued": 1, "unsent": 0,
  "items": [{ "id": "1c142d81c7", "phone": "+48500600700",
              "status": "queued", "parts": 1 }] }
```

Odpowiedź błędu:

```json
{ "error": { "code": 3101, "type": "SendError", "message": "Wiadomość jest pusta" } }
```

Zasady połączenia: limit czasu 30 sekund, trzy próby z odstępem 1, 5 i 25 sekund, wyłącznie dla błędów sieciowych i kodów 5xx. Błąd merytoryczny operatora nie jest ponawiany, tylko oznacza wiadomość jako `failed`.

## Długość i kodowanie

| Kodowanie | Jedna część | Kolejne części |
|---|---|---|
| GSM 7-bit, bez polskich znaków | 160 znaków | 153 znaki |
| UCS-2, z polskimi znakami | 70 znaków | 67 znaków |

Klasa `SmsCounter` liczy części przed wysyłką. Edytor szablonu pokazuje na żywo liczbę znaków, liczbę części i ostrzeżenie przy przekroczeniu jednej części, bo druga część kosztuje drugi kredyt.

Domyślne szablony są napisane bez polskich znaków diakrytycznych, żeby mieściły się w 160 znakach. Użytkownik może je zmienić, ale wtedy widzi, ile to kosztuje.

**Znaki liczone podwójnie w GSM 7-bit:** `^ { } \ [ ] ~ | €`. Emoji zawsze wymuszają UCS-2 i skracają wiadomość do 70 znaków, o czym edytor informuje wprost.

## Kolejka

SMS nigdy nie wychodzi w trakcie obsługi żądania HTTP. Zawsze przez kolejkę.

```
zdarzenie (utworzenie wizyty, zmiana statusu, cron)
  → SmsScheduler tworzy wiersz w sms_messages ze statusem queued i scheduled_at
  → wiersz w jobs typu SendSms
  → cron co minutę uruchamia runner kolejki
  → SmsGateway wysyła, status przechodzi na sent i zapisuje provider_message_id
  → webhook /webhook/sms/dlr ustawia delivered albo undelivered
```

Powody takiego układu: wysyłka nie spowalnia interfejsu, awaria operatora nie blokuje pracy salonu, a przy błędzie da się ponowić bez duplikatu.

### Zabezpieczenia przed duplikatem

- `appointments.reminder_sent_at` jest ustawiane w tej samej transakcji, w której powstaje wiadomość. Drugi przebieg crona nie znajdzie już tej wizyty.
- Runner blokuje zadanie przez `UPDATE jobs SET locked_at, locked_by WHERE id = ? AND locked_at IS NULL`. Dwa równoległe przebiegi crona nie wezmą tego samego zadania.
- Zadanie zablokowane dłużej niż 10 minut jest odblokowywane i liczone jako nieudana próba.
- Po trzech nieudanych próbach zadanie ma status `failed` i pojawia się w `/admin/health`.

## Rodzaje wiadomości

| Typ | Kiedy | Wymagana zgoda | Domyślnie |
|---|---|---|---|
| `reminder` | 24 godziny przed wizytą | SMS | włączone |
| `confirmation` | Zaraz po utworzeniu wizyty | SMS | wyłączone |
| `cancellation` | Po odwołaniu wizyty przez salon | SMS | wyłączone |
| `after_visit` | 2 godziny po zakończeniu wizyty | SMS | wyłączone |
| `birthday` | W dniu urodzin, o ustalonej godzinie | marketing | wyłączone |
| `winback` | Klientka dawno nie była | marketing | wyłączone, ręczne wysłanie |
| `manual` | Wysyłka z panelu | SMS | zawsze dostępne |

### Godzina wysyłki

Przypomnienie wysyłane 24 godziny przed wizytą o 7:00 rano jest wysyłane o 7:00 dzień wcześniej, co jest akceptowalne. Ale przypomnienie o wizycie o 8:00 wysłane o 8:00 poprzedniego dnia to za mało czasu na reakcję, a wysłane o 22:00 to wiadomość w nocy.

Reguła: wiadomości automatyczne wychodzą wyłącznie w oknie od 8:00 do 20:00 czasu lokalnego salonu. Jeśli wyliczona godzina wypada poza oknem, wysyłka jest przesuwana na najbliższą 8:00. Ustawienie `reminder_send_hour` pozwala wymusić stałą godzinę, np. wszystkie przypomnienia o 18:00 dnia poprzedniego, co jest zalecanym ustawieniem domyślnym.

## Szablony i zmienne

| Zmienna | Podstawia |
|---|---|
| `{imie}` | Imię klientki |
| `{nazwisko}` | Nazwisko |
| `{salon}` | Nazwa salonu |
| `{data}` | Data wizyty, np. 24.09 |
| `{dzien}` | Nazwa dnia, np. czwartek |
| `{godzina}` | Godzina wizyty, np. 15:30 |
| `{usluga}` | Nazwa pierwszej usługi |
| `{pracownik}` | Imię pracownika |
| `{telefon}` | Telefon salonu |
| `{link}` | Link do rezerwacji online salonu |

Zmienna bez wartości podstawia pusty ciąg, a podwójne spacje są usuwane po podstawieniu. Szablon z nieznaną zmienną nie zapisze się, edytor pokazuje błąd.

### Treści domyślne

**Przypomnienie**
```
Czesc {imie}! Przypominamy o wizycie w {salon} jutro o {godzina}. Do zobaczenia!
```
131 znaków przy typowych wartościach, jedna część, bez polskich znaków.

**Potwierdzenie**
```
{imie}, Twoja wizyta w {salon} zostala umowiona na {data} o {godzina}. Do zobaczenia!
```

**Po wizycie**
```
Dziekujemy za wizyte w {salon}! Mamy nadzieje, ze wszystko sie podobalo. Do zobaczenia wkrotce.
```

**Urodziny**
```
{imie}, wszystkiego najlepszego! Zespol {salon}
```

**Odzyskanie klientki**
```
Czesc {imie}! Dawno Cie u nas nie bylo. Mamy wolne terminy w tym tygodniu, zapraszamy: {telefon}
```

Przy wiadomościach marketingowych, zgodnie z prawem telekomunikacyjnym, dobrą praktyką jest dopisek o możliwości rezygnacji. System dokłada go automatycznie do typów `birthday` i `winback`, a treść dopisku jest edytowalna w ustawieniach.

## Pakiety i saldo

### Pakiety startowe

| Pakiet | Kredyty | Cena brutto | Za SMS |
|---|---:|---:|---:|
| Mały | 100 | 15 zł | 0,15 zł |
| Średni | 500 | 60 zł | 0,12 zł |
| Duży | 1000 | 100 zł | 0,10 zł |
| Bardzo duży | 5000 | 450 zł | 0,09 zł |

sms.pl podaje stawki od około 6 groszy za wiadomość, więc marża mieści się w przedziale od 35 do 60 procent. Przed uruchomieniem sprzedaży trzeba to przeliczyć na podstawie faktycznego cennika po negocjacji i ustawić wartość `sms_cost_per_credit` w `app_settings`, żeby panel operatora liczył marżę.

### Reguły salda

1. Saldo w `salons.sms_balance` jest wyłącznie sumą `sms_transactions.credits`. Nigdy nie jest ustawiane bezpośrednio.
2. Pobranie kredytów następuje w momencie przekazania wiadomości operatorowi, nie w momencie zaplanowania.
3. Wiadomość wieloczęściowa pobiera tyle kredytów, ile ma części.
4. Odrzucenie przez operatora zwraca kredyty transakcją typu `refund`.
5. Zerowe saldo wstrzymuje wysyłkę automatyczną. Wiadomości zostają w kolejce ze statusem `queued` przez 24 godziny i wychodzą po doładowaniu, jeśli termin wizyty jeszcze nie minął.
6. Przy saldzie poniżej 20 kredytów panel pokazuje pasek ostrzegawczy, a przy zerze wysyła e-mail do właścicielki.
7. Nowy salon dostaje 20 kredytów na start jako transakcja typu `bonus`, żeby mógł przetestować funkcję.

### Zakup

Zakup pakietu idzie przez Stripe Checkout w trybie jednorazowym. Kredyty dopisuje dopiero webhook potwierdzający płatność, nigdy powrót z bramki. Szczegóły w `07-platnosci-i-abonament.md`.

## Raporty doręczeń

Operator woła `/webhook/sms/dlr` z parametrami `#SMSID#`, `#STAN#`, `#DATA#`, `#PRZYCZYNA#`. Obsługa:

1. Znajdź wiadomość po `provider_message_id`.
2. Zmapuj stan operatora na status wewnętrzny.
3. Zapisz `delivered_at` albo `error_code` i `error_message`.
4. Przy trwałym niedoręczeniu, np. nieistniejący numer, zwróć kredyty i oznacz numer klientki jako wymagający sprawdzenia.

Adres webhooka przyjmuje wyłącznie żądania z adresów IP operatora oraz z poprawnym tokenem w ścieżce, nadanym przy konfiguracji.

## Odpowiedzi klientek, etap 8

Potwierdzanie wizyty odpowiedzią wymaga numeru zwrotnego u operatora. Treść przypomnienia zmienia się wtedy na:

```
Czesc {imie}! Wizyta w {salon} jutro o {godzina}.
Potwierdz: TAK  Odwolaj: NIE
```

Obsługa wiadomości przychodzącej:

1. Zapis do `sms_inbox`.
2. Znalezienie klientki po numerze, w obrębie salonów, do których ten numer zwrotny jest przypisany.
3. Znalezienie najbliższej wizyty w przyszłości o statusie `scheduled` lub `confirmed`.
4. `TAK`, `T`, `OK`, `POTWIERDZAM` ustawiają `confirmed` i `confirmed_at`.
5. `NIE`, `ODWOLAJ`, `ODWOŁAJ`, `N` ustawiają `cancelled` z powodem "klientka odwołała SMS-em" i wysyłają powiadomienie do salonu.
6. Cokolwiek innego zostaje w skrzynce z akcją `none` i jest widoczne w panelu jako wiadomość do przeczytania.

Klientka zawsze dostaje krótkie potwierdzenie przyjęcia odpowiedzi. Bez tego będzie dzwonić, żeby sprawdzić, czy doszło.

## Historia SMS `/sms/historia`

Tabela: data, klientka, typ, treść, status, liczba części, koszt. Filtry po statusie, typie i zakresie dat. Nieudane wiadomości mają widoczny powód i przycisk ponowienia. Eksport do CSV przy uprawnieniu `clients.export`.

## Limity zabezpieczające

Przed pomyłką i przed nadużyciem:

- Maksymalnie 500 wiadomości na salon na dobę, wyżej tylko po podniesieniu limitu przez operatora systemu.
- Maksymalnie 3 wiadomości do jednego numeru w ciągu doby, nie licząc odpowiedzi na wiadomości przychodzące.
- Wysyłka grupowa powyżej 50 odbiorców wymaga potwierdzenia z podaniem liczby odbiorców i kosztu w kredytach.
- Blokada wysyłki na numery spoza Polski, dopóki nie zostanie to świadomie włączone, bo stawki są wielokrotnie wyższe.
