mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Add charges and the billing limit for trusted apps
This commit is contained in:
7 files changed
+799
-60
No files matched your search
@@ -1,60 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\Resources\Transaction;
|
||||
use Throwable;
|
||||
|
||||
trait ManagesBalance
|
||||
{
|
||||
use HasBillable;
|
||||
|
||||
/**
|
||||
* Get balance from the current user.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function balance(): float
|
||||
{
|
||||
return $this->getUserData()->current_balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get charges from the current user.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function charges(): float
|
||||
{
|
||||
return $this->getUserData()->current_charges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing overview of the current user: current_balance, current_charges, vat_rate,
|
||||
* has_billing_address, has_default_payment_method, open_orders.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function billingOverview(): object
|
||||
{
|
||||
return $this->anikeenId()->request('GET', 'v1/billing')->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge given amount (deposit) from one of the current user's payment methods.
|
||||
*
|
||||
* @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals).
|
||||
* @param string $paymentMethodId Stripe payment method ID (pm_…).
|
||||
* @param array $options Ignored; Stripe options can no longer be passed through.
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction
|
||||
{
|
||||
return (new Transaction(fn() => $this->anikeenId()
|
||||
->request('POST', 'v1/billing/charge', [
|
||||
'amount' => $amount,
|
||||
'payment_method_id' => $paymentMethodId,
|
||||
])))
|
||||
->setBillable($this);
|
||||
}
|
||||
}␍
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\Resources\Transaction;
|
||||
use Throwable;
|
||||
|
||||
trait ManagesBalance
|
||||
{
|
||||
use HasBillable;
|
||||
|
||||
/**
|
||||
* Get balance from the current user.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function balance(): float
|
||||
{
|
||||
return $this->getUserData()->current_balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get charges from the current user.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function charges(): float
|
||||
{
|
||||
return $this->getUserData()->current_charges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing overview of the current user: current_balance, current_charges, vat_rate,
|
||||
* has_billing_address, has_default_payment_method, open_orders and billing_limit.
|
||||
*
|
||||
* billing_limit is the user's monthly billing limit {object: "billing_limit", user_id,
|
||||
* currency, basis, limit, used, remaining, remaining_net, vat_rate, reached, resets_at,
|
||||
* version} (`limit`, `remaining` and `remaining_net` null without a limit; wrap it in
|
||||
* `new \Anikeen\Id\Resources\BillingLimit(…)` for its helpers), or null while the user has no
|
||||
* limit and can't set one yet. Older Anikeen ID releases don't send the key.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function billingOverview(): object
|
||||
{
|
||||
return $this->anikeenId()->request('GET', 'v1/billing')->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge given amount (deposit) from one of the current user's payment methods.
|
||||
*
|
||||
* @param float|string $amount Amount in euros (0.50 to 10,000, at most 2 decimals).
|
||||
* @param string $paymentMethodId Stripe payment method ID (pm_…).
|
||||
* @param array $options Ignored; Stripe options can no longer be passed through.
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function charge(float|string $amount, string $paymentMethodId, array $options = []): Transaction
|
||||
{
|
||||
return (new Transaction(fn() => $this->anikeenId()
|
||||
->request('POST', 'v1/billing/charge', [
|
||||
'amount' => $amount,
|
||||
'payment_method_id' => $paymentMethodId,
|
||||
])))
|
||||
->setBillable($this);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace Anikeen\Id\Concerns;
|
||||
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Resources\AppCharge;
|
||||
use Anikeen\Id\Resources\AppCheckoutSessions;
|
||||
use Anikeen\Id\Resources\AppOrders;
|
||||
use Anikeen\Id\Resources\BillingLimit;
|
||||
use Anikeen\Id\Result;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
use Throwable;
|
||||
@@ -123,4 +125,101 @@ trait ManagesUserBilling
|
||||
{
|
||||
return $this->asApp()->request('POST', 'v1/admin/transactions', [...$attributes, 'user_id' => $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Book a charge onto a user's open monthly bill of the app, e.g. a shipped item: one line,
|
||||
* no subscription, charged with the bill on the 1st. Trusted apps that Anikeen allowed to
|
||||
* charge (with a maximum per charge), for users connected to the app (they authorised it or
|
||||
* the app bills them). The user's billing limit is checked when it is booked.
|
||||
*
|
||||
* Retrying with the same idempotency key and the same fields returns the existing charge
|
||||
* (also after the limit was reached); other fields with that key: 409
|
||||
* "idempotency_key_conflict" (`charge_id` in body()).
|
||||
*
|
||||
* @param string $userId The user's Anikeen ID.
|
||||
* @param array{
|
||||
* idempotency_key: string,
|
||||
* name: string,
|
||||
* description?: null|string,
|
||||
* type?: string,
|
||||
* quantity: int,
|
||||
* unit_price: float|int|string,
|
||||
* currency_iso?: string,
|
||||
* group?: null|string,
|
||||
* occurred_at?: string,
|
||||
* reference?: null|string,
|
||||
* metadata?: null|array<string, string>,
|
||||
* image_url?: null|string
|
||||
* } $attributes idempotency_key: unique per app and user, ≤ 255 (e.g. "shipment-123").
|
||||
* name: the line on the bill, ≤ 255. description: ≤ 255.
|
||||
* type: digital (default), physical or physical-reduced (the VAT treatment).
|
||||
* quantity: 1–10000 pieces. unit_price: net per piece, > 0, ≤ 100000, up to 6
|
||||
* decimals; quantity × unit_price rounded to cents must be at least 0.01.
|
||||
* currency_iso: EUR only (default).
|
||||
* group: null, or a group in which the app already bills the user (a running
|
||||
* subscription or an open monthly bill of the app).
|
||||
* occurred_at: the service date, not in the future, at most 31 days ago
|
||||
* (default now).
|
||||
* reference: the app's own id (≤ 255). metadata: ≤ 20 keys (≤ 40 characters),
|
||||
* string values ≤ 500. Both are not on the bill; put nothing sensitive there.
|
||||
* image_url: http(s) URL of an image for the line.
|
||||
*
|
||||
* @throws ApiException 403 "client_not_allowed" (not a trusted app), "charges_not_enabled";
|
||||
* 404 "user_not_found" (unknown, deleted or not connected); 409 "billing_limit_reached"
|
||||
* (isBillingLimitReached(); body() has limit, used, remaining, remaining_net, required,
|
||||
* currency, resets_at), "charge_exceeds_maximum" (+ max_charge_net),
|
||||
* "group_not_allowed", "idempotency_key_conflict" (+ charge_id),
|
||||
* "billing_address_missing"; 422 validation; 429 "too_many_requests"
|
||||
*/
|
||||
public function createChargeForUser(string $userId, array $attributes): AppCharge
|
||||
{
|
||||
return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId), $attributes)->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A charge of the app (or of a client delegated to the same app) for a user.
|
||||
*
|
||||
* @throws ApiException 403 "client_not_allowed", "charges_not_enabled"; 404 "user_not_found",
|
||||
* "charge_not_found"; 429 "too_many_requests"
|
||||
*/
|
||||
public function findChargeForUser(string $userId, string $chargeId): AppCharge
|
||||
{
|
||||
return new AppCharge(AppRequest::send('GET', $this->chargesPath($userId, $chargeId))->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a charge while its bill is open: the line is removed and the bill's total drops. An
|
||||
* already voided charge is returned as it is. Once the bill is closed only a refund of the
|
||||
* order by Anikeen credits it.
|
||||
*
|
||||
* @param string|null $reason why (≤ 255), stored with the charge
|
||||
* @throws ApiException 409 "charge_already_billed" (+ charge_id); 404 "charge_not_found",
|
||||
* "user_not_found"; 403 "client_not_allowed", "charges_not_enabled"; 429 "too_many_requests"
|
||||
*/
|
||||
public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge
|
||||
{
|
||||
$payload = $reason === null ? null : ['reason' => $reason];
|
||||
|
||||
return new AppCharge(AppRequest::send('POST', $this->chargesPath($userId, $chargeId) . '/void', $payload)->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's monthly billing limit as the app sees it, e.g. to pause paid features while it is
|
||||
* reached. Advisory only: it reserves nothing; a charge is checked when it is booked. Trusted
|
||||
* apps that may charge or that Anikeen allowed to read limits, for users connected to the app.
|
||||
*
|
||||
* @throws ApiException 403 "client_not_allowed", "billing_limit_not_enabled"; 404
|
||||
* "user_not_found"; 429 "too_many_requests"
|
||||
*/
|
||||
public function billingLimitForUser(string $userId): BillingLimit
|
||||
{
|
||||
return new BillingLimit(AppRequest::send('GET', sprintf('v1/client/users/%s/billing-limit', rawurlencode($userId)))->data);
|
||||
}
|
||||
|
||||
private function chargesPath(string $userId, ?string $chargeId = null): string
|
||||
{
|
||||
$path = sprintf('v1/client/users/%s/charges', rawurlencode($userId));
|
||||
|
||||
return $chargeId === null ? $path : $path . '/' . rawurlencode($chargeId);
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,16 @@ class ApiException extends ResourceException
|
||||
return $this->status === 409;
|
||||
}
|
||||
|
||||
/**
|
||||
* A charge was refused because it would take the user's open monthly bill above their billing
|
||||
* limit (409 "billing_limit_reached"). body() has limit, used, remaining, remaining_net,
|
||||
* required, currency and resets_at.
|
||||
*/
|
||||
public function isBillingLimitReached(): bool
|
||||
{
|
||||
return $this->status === 409 && $this->error === 'billing_limit_reached';
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID service could not be reached (network error, timeout) or failed (5xx): retry later.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* A charge a trusted app booked onto a user's open monthly bill, see
|
||||
* AnikeenId::createChargeForUser(). Amounts are decimal strings (`unit_price` with six decimals,
|
||||
* the others with two).
|
||||
*
|
||||
* @property string $object "charge"
|
||||
* @property string $id
|
||||
* @property string $user_id
|
||||
* @property string $status open (its bill is open), billed (the bill was closed) or voided
|
||||
* @property string $idempotency_key
|
||||
* @property string $name the line on the bill
|
||||
* @property ?string $description
|
||||
* @property string $type digital, physical or physical-reduced (the VAT treatment)
|
||||
* @property int $quantity
|
||||
* @property string $unit "piece"
|
||||
* @property string $unit_price net per piece
|
||||
* @property string $net_amount
|
||||
* @property string $gross_amount what the charge added to the bill, VAT included
|
||||
* @property string $currency_iso
|
||||
* @property ?string $group
|
||||
* @property string $occurred_at the line's service date (ISO 8601)
|
||||
* @property ?string $reference the app's own id
|
||||
* @property object $metadata the app's own data (string values)
|
||||
* @property ?string $order_id the monthly bill (order) the line is on; follows the line when a rollover moves it
|
||||
* @property ?string $voided_at
|
||||
* @property ?string $void_reason
|
||||
* @property string $created_at
|
||||
*/
|
||||
class AppCharge implements JsonSerializable
|
||||
{
|
||||
public const STATUS_OPEN = 'open';
|
||||
|
||||
public const STATUS_BILLED = 'billed';
|
||||
|
||||
public const STATUS_VOIDED = 'voided';
|
||||
|
||||
public function __construct(protected object $data)
|
||||
{
|
||||
}
|
||||
|
||||
public function __get(string $name): mixed
|
||||
{
|
||||
return $this->data->{$name} ?? null;
|
||||
}
|
||||
|
||||
public function __isset(string $name): bool
|
||||
{
|
||||
return isset($this->data->{$name});
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return $this->data->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* open, billed or voided (the STATUS_* constants).
|
||||
*/
|
||||
public function status(): string
|
||||
{
|
||||
return $this->data->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Its bill is still open: the charge can be voided.
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return $this->status() === self::STATUS_OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Its bill was closed (paid, waiting for payment or cancelled): only a refund of the order by
|
||||
* Anikeen credits it.
|
||||
*/
|
||||
public function isBilled(): bool
|
||||
{
|
||||
return $this->status() === self::STATUS_BILLED;
|
||||
}
|
||||
|
||||
public function isVoided(): bool
|
||||
{
|
||||
return $this->status() === self::STATUS_VOIDED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Net, e.g. "8.00".
|
||||
*/
|
||||
public function netAmount(): string
|
||||
{
|
||||
return $this->data->net_amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gross, what the charge added to the bill, e.g. "9.52".
|
||||
*/
|
||||
public function grossAmount(): string
|
||||
{
|
||||
return $this->data->gross_amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* The monthly bill (order) the charge is on, null once its line is gone (voided).
|
||||
*/
|
||||
public function orderId(): ?string
|
||||
{
|
||||
return $this->data->order_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function metadata(): array
|
||||
{
|
||||
return (array) ($this->data->metadata ?? []);
|
||||
}
|
||||
|
||||
public function occurredAt(): ?DateTimeImmutable
|
||||
{
|
||||
return isset($this->data->occurred_at) ? new DateTimeImmutable($this->data->occurred_at) : null;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return (array) $this->data;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* A user's monthly billing limit: one gross limit for their whole open monthly bill across all
|
||||
* Anikeen services, see AnikeenId::billingLimitForUser() and the `billing_limit` key of
|
||||
* billingOverview(). Amounts are decimal strings with two decimals.
|
||||
*
|
||||
* Without a limit `limit`, `remaining` and `remaining_net` are null; for an app `used` is null
|
||||
* too (an app sees the user's whole open bill only while they have set a limit). `used_by_app`
|
||||
* (the open bills of your app) is there only when an app reads the limit.
|
||||
*
|
||||
* @property string $object "billing_limit"
|
||||
* @property string $user_id
|
||||
* @property string $currency "EUR"
|
||||
* @property string $basis "gross"
|
||||
* @property ?string $limit
|
||||
* @property ?string $used the user's open monthly bills across all Anikeen services
|
||||
* @property ?string $used_by_app the open monthly bills of your app (app reads only)
|
||||
* @property ?string $remaining max(0, limit − used)
|
||||
* @property ?string $remaining_net remaining at the user's VAT rate, rounded down (a hint)
|
||||
* @property int|float|null $vat_rate null without a billing address
|
||||
* @property bool $reached used >= limit: start no new paid service
|
||||
* @property string $resets_at when this month's bills are closed (ISO 8601)
|
||||
* @property int $version only goes up; keep the highest seen (also in the billing_limit.* webhooks)
|
||||
*/
|
||||
class BillingLimit implements JsonSerializable
|
||||
{
|
||||
public function __construct(protected object $data)
|
||||
{
|
||||
}
|
||||
|
||||
public function __get(string $name): mixed
|
||||
{
|
||||
return $this->data->{$name} ?? null;
|
||||
}
|
||||
|
||||
public function __isset(string $name): bool
|
||||
{
|
||||
return isset($this->data->{$name});
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has set a limit (`0.00` is one too: no new charges at all).
|
||||
*/
|
||||
public function hasLimit(): bool
|
||||
{
|
||||
return $this->limit() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gross, null without a limit.
|
||||
*/
|
||||
public function limit(): ?string
|
||||
{
|
||||
return $this->data->limit ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's open monthly bills across all Anikeen services (gross). Null for an app while the
|
||||
* user has no limit.
|
||||
*/
|
||||
public function used(): ?string
|
||||
{
|
||||
return $this->data->used ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open monthly bills of your app (gross); null outside an app read (billingOverview()).
|
||||
* `used − used_by_app` is what the user spends on their other Anikeen services.
|
||||
*/
|
||||
public function usedByApp(): ?string
|
||||
{
|
||||
return $this->data->used_by_app ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gross room left (never negative), null without a limit.
|
||||
*/
|
||||
public function remaining(): ?string
|
||||
{
|
||||
return $this->data->remaining ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The room left as a net amount at the user's VAT rate, rounded down: a hint only (the VAT of
|
||||
* a charge depends on its type). Null without a limit or a billing address.
|
||||
*/
|
||||
public function remainingNet(): ?string
|
||||
{
|
||||
return $this->data->remaining_net ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's VAT rate in percent (e.g. 19.0), null without a billing address.
|
||||
*/
|
||||
public function vatRate(): ?float
|
||||
{
|
||||
return isset($this->data->vat_rate) ? (float) $this->data->vat_rate : null;
|
||||
}
|
||||
|
||||
public function currency(): string
|
||||
{
|
||||
return $this->data->currency ?? 'EUR';
|
||||
}
|
||||
|
||||
/**
|
||||
* The limit is reached (used >= limit): start no new paid service for the user.
|
||||
*/
|
||||
public function isReached(): bool
|
||||
{
|
||||
return (bool) ($this->data->reached ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* When this month's bills are closed and `used` drops.
|
||||
*/
|
||||
public function resetsAt(): ?DateTimeImmutable
|
||||
{
|
||||
return isset($this->data->resets_at) ? new DateTimeImmutable($this->data->resets_at) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only goes up: keep the highest one seen and ignore webhooks with a lower or equal one.
|
||||
*/
|
||||
public function version(): int
|
||||
{
|
||||
return (int) ($this->data->version ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a charge of $gross (gross, e.g. "4.76") would fit as of this read: always without a
|
||||
* limit, else at most `remaining`. Advisory only: it reserves nothing, the binding check is
|
||||
* the booking (409 "billing_limit_reached").
|
||||
*/
|
||||
public function allows(string|int|float $gross): bool
|
||||
{
|
||||
if (!$this->hasLimit()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$remaining = $this->remaining();
|
||||
|
||||
return $remaining !== null && self::compare($gross, $remaining) <= 0;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return (array) $this->data;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two non-negative decimal amounts exactly (as digit strings, no float rounding);
|
||||
* negative amounts count as zero.
|
||||
*/
|
||||
private static function compare(string|int|float $a, string|int|float $b): int
|
||||
{
|
||||
return strcmp(self::micros($a), self::micros($b)) <=> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount in millionths as a zero-padded digit string of a fixed width.
|
||||
*/
|
||||
private static function micros(string|int|float $amount): string
|
||||
{
|
||||
$amount = is_float($amount) ? number_format($amount, 6, '.', '') : trim((string) $amount);
|
||||
|
||||
if (str_starts_with($amount, '-')) {
|
||||
return str_repeat('0', 40);
|
||||
}
|
||||
|
||||
[$whole, $fraction] = array_pad(explode('.', ltrim($amount, '+'), 2), 2, '');
|
||||
$digits = preg_replace('/\D/', '', $whole) . substr(str_pad(preg_replace('/\D/', '', $fraction), 6, '0'), 0, 6);
|
||||
|
||||
return str_pad(ltrim($digits, '0'), 40, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user