Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions app/lib/translations/translations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -357,13 +357,13 @@ class AppTranslations extends Translations {
},
'de_DE': {
'introduction_title': 'Willkommen bei Spacepad!',
'introduction_text': 'Beginnen sie, indem sie ihr gerät mit ihrem raum verbinden.',
'connect_to_account': 'Mit ihrem konto verbinden',
'enter_connect_code': 'Bitte geben sie den verbindungscode ein',
'introduction_text': 'Beginnen Sie, indem Sie Ihr Gerät mit Ihrem Raum verbinden.',
'connect_to_account': 'Mit Ihrem Konto verbinden',
'enter_connect_code': 'Bitte geben Sie den Verbindungscode ein',
'choose_display': 'Anzeige auswählen',
'choose_room_display': 'Welchen raum möchten sie auf diesem gerät anzeigen?',
'choose_room_display': 'Welchen Raum möchten Sie auf diesem Gerät anzeigen?',
'select_display': 'Anzeige auswählen',
'please_select_display': 'Bitte wählen sie eine anzeige aus.',
'please_select_display': 'Bitte wählen Sie eine Anzeige aus.',
'continue': 'Weiter',
'next': 'Nächste',
'next_event_title': '@start - @end, @summary',
Expand Down Expand Up @@ -392,28 +392,28 @@ class AppTranslations extends Translations {
'event_cancelled': 'Termin storniert',
'could_not_cancel_event': 'Termin konnte nicht storniert werden',
'url_hint': 'https://ihre-instanz.com',
'check_connection': 'Ein unerwarteter fehler ist aufgetreten. Bitte überprüfen sie ihre internetverbindung',
'code_incorrect': 'Ihr code ist falsch. Bitte aktualisieren sie ihre dashboard, um den neuesten code zu erhalten',
'check_connection': 'Ein unerwarteter Fehler ist aufgetreten. Bitte überprüfen Sie Ihre Internetverbindung',
'code_incorrect': 'Ihr Code ist falsch. Bitte aktualisieren Sie Ihr Dashboard, um den neuesten Code zu erhalten',
'self_hosted': 'Selbst gehostet',
'cloud_hosted': 'Cloud-gehostet',
'self_hosted_url': 'URL der selbst gehosteten instanz',
'connect_code_explanation': 'Um einen verbindungscode zu erhalten, müssen sie:\n1. Sich registrieren unter app.spacepad.io/register, oder\n2. Ihre eigene cloud-umgebung einrichten, siehe: spacepad.io\nGehen sie dann zu ihrem dashboard, um den verbindungscode zu erhalten.',
'invalid_url': 'Bitte geben sie eine gültige URL ein (z.B. https://ihre-instanz.com)',
'server_unreachable_self_hosted': 'Verbindung zum server konnte nicht hergestellt werden. Bitte überprüfen sie, ob die URL korrekt ist und der server läuft.',
'self_hosted_url': 'URL der selbst gehosteten Instanz',
'connect_code_explanation': 'Um einen Verbindungscode zu erhalten, müssen Sie:\n1. Sich registrieren unter app.spacepad.io/register, oder\n2. Ihre eigene Cloud-Umgebung einrichten, siehe: spacepad.io\nGehen Sie dann zu Ihrem Dashboard, um den Verbindungscode zu erhalten.',
'invalid_url': 'Bitte geben Sie eine gültige URL ein (z.B. https://ihre-instanz.com)',
'server_unreachable_self_hosted': 'Verbindung zum Server konnte nicht hergestellt werden. Bitte überprüfen Sie, ob die URL korrekt ist und der Server läuft.',
'ssl_error': 'Konnte keine sichere Verbindung herstellen. Dies kann auf älteren Android-Geräten auftreten. Bitte stellen Sie sicher, dass Sie die neueste Version der App installiert haben.',
'login_failed': 'Ihr verbindungscode ist falsch. Bitte versuchen sie es erneut.',
'login_failed': 'Ihr Verbindungscode ist falsch. Bitte versuchen Sie es erneut.',
'reserved': 'Reserviert',
'reserve': 'Reservieren',
'check_in_now': 'Check-in für meeting',
'check_in_now': 'Check-in für Meeting',
'check_in': 'Einchecken',
'x_starts_in_x_minutes': '@meeting startet in @minutes min',
'check_in_within_x_minutes': 'Check-in in @minutes min',
'checked_in': 'Erfolgreich eingecheckt!',
'could_not_check_in': 'Konnte nicht einchecken',
'view_schedule': 'Zeitplan anzeigen',
'todays_schedule': 'Heutiger zeitplan',
'no_upcoming_events': 'Keine anstehenden termine',
'no_events_today': 'Keine termine für heute geplant',
'todays_schedule': 'Heutiger Zeitplan',
'no_upcoming_events': 'Keine anstehenden Termine',
'no_events_today': 'Keine Termine für heute geplant',
'meeting': 'Besprechung',
'admin_actions_enabled': 'Administratoraktionen für @seconds Sekunden aktiviert',
'refresh_data': 'Daten aktualisieren',
Expand Down
39 changes: 39 additions & 0 deletions backend/app/Console/Commands/UpdateLemonSqueezySubscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,45 @@ public function handle(): int
// for a while the only sign of trouble was the exit code, with nothing
// to say which workspace caused it.
if (! $this->usage->pushUnits($subscription->lemon_squeezy_id, $units, ['workspace_id' => $workspace->id])) {
// A refusal is often Lemon Squeezy's way of saying the subscription
// is not what we think it is: a missed webhook left one expired
// subscription looking live for months. Pull the truth before
// deciding whether this is an error at all.
$remote = $this->usage->refreshSubscription($subscription);

if ($remote !== null && ! $subscription->valid()) {
$this->line(sprintf(
'Skipping workspace %s: subscription %s is %s at Lemon Squeezy, local row synced',
$workspace->id,
$subscription->lemon_squeezy_id,
$subscription->status,
));
$skippedCount++;

continue;
}

// Nothing we can do from here: Lemon Squeezy only lets the customer
// change a PayPal subscription, through the portal. Say so with the
// numbers, since the quantity at Lemon Squeezy will drift from the
// real usage until someone acts on it.
if (($remote['payment_processor'] ?? null) === 'paypal') {
$billed = $remote['first_subscription_item']['quantity'] ?? null;
$message = sprintf(
'Workspace %s pays through PayPal, which Lemon Squeezy cannot update via the API: billed for %s, uses %d units',
$workspace->id,
$billed ?? '?',
$units,
);
$this->warn($message);
if ($billed !== $units) {
Log::warning($message, ['workspace_id' => $workspace->id, 'subscription_id' => $subscription->lemon_squeezy_id]);
}
$skippedCount++;

continue;
}

$errorCount++;
$this->error(sprintf(
'Lemon Squeezy refused workspace %s (subscription %s, %d units)',
Expand Down
37 changes: 37 additions & 0 deletions backend/app/Services/LemonSqueezyUsageService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use LemonSqueezy\Laravel\Subscription;

/**
* Pushing billable usage to Lemon Squeezy.
Expand Down Expand Up @@ -140,6 +141,42 @@ public function pushUnits(string $subscriptionId, int $units, array $context = [
return true;
}

/**
* Overwrite the local subscription row with what Lemon Squeezy currently says.
*
* Webhooks are the normal way status reaches us, but one can be missed: a subscription
* sat locally as past_due for eight months after it had expired at Lemon Squeezy, and
* was pushed, and refused, every hour of it. Called when a push is refused, so a
* subscription that is no longer live stops being treated as one.
*
* Returns the remote attributes, because the caller also needs what the local row
* does not keep: the payment processor. Lemon Squeezy cannot change a PayPal
* subscription through the API at all (the customer has to, in the portal), and
* answers a quantity update on one with a bare 500.
*
* @return array<string, mixed>|null
*/
public function refreshSubscription(Subscription $subscription): ?array
{
$response = $this->request()->get(self::BASE.'/subscriptions/'.$subscription->lemon_squeezy_id);

$attributes = $response->json('data.attributes');

if (! $response->successful() || ! is_array($attributes) || ! isset($attributes['status'])) {
Log::warning('Could not refresh Lemon Squeezy subscription', [
'subscription_id' => $subscription->lemon_squeezy_id,
'status' => $response->status(),
'error' => $this->errorDetail($response),
]);

return null;
}

$subscription->sync($attributes);

return $attributes;
}

public function hasApiKey(): bool
{
return (bool) config('lemon-squeezy.api_key');
Expand Down
89 changes: 89 additions & 0 deletions backend/tests/Feature/UpdateLemonSqueezyUsageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,92 @@ function pushedQuantity(): ?int

Http::assertNothingSent();
});

test('a subscription that expired at Lemon Squeezy without a webhook is synced locally and skipped', function () {
// Production case: the subscription_expired webhook never arrived, so the local row
// stayed past_due with no ends_at and was pushed, and refused, every hour for months.
Http::fake([
'*lemonsqueezy.com/v1/subscriptions/*' => Http::response([
'data' => ['attributes' => [
'status' => 'expired',
'product_id' => 1,
'variant_id' => 1,
'renews_at' => null,
'ends_at' => '2026-03-17T15:13:39.000000Z',
'subscription_items' => [['id' => 'item_1']],
]],
]),
'*lemonsqueezy.com/v1/subscription-items/*' => Http::response(['message' => 'An unexpected error occurred.'], 500),
'*lemonsqueezy.com/v1/usage-records*' => Http::response(['errors' => [['detail' => 'not metered']]], 422),
]);

$owner = User::factory()->active()->create();
$workspace = subscribedWorkspace($owner);
DB::table('lemon_squeezy_subscriptions')->where('billable_id', $workspace->id)->update(['status' => 'past_due']);
Display::factory()->create(['workspace_id' => $workspace->id]);

$exitCode = Artisan::call('app:update-lemonsqueezy-subscriptions');

expect($exitCode)->toBe(0)
->and(Artisan::output())->toContain('expired');

$row = DB::table('lemon_squeezy_subscriptions')->where('billable_id', $workspace->id)->first();
expect($row->status)->toBe('expired')
->and($row->ends_at)->not->toBeNull();
});

test('a refused push on a subscription that is still live at Lemon Squeezy stays an error', function () {
Http::fake([
'*lemonsqueezy.com/v1/subscriptions/*' => Http::response([
'data' => ['attributes' => [
'status' => 'active',
'product_id' => 1,
'variant_id' => 1,
'renews_at' => now()->addMonth()->toIso8601String(),
'ends_at' => null,
'subscription_items' => [['id' => 'item_1']],
]],
]),
'*lemonsqueezy.com/v1/subscription-items/*' => Http::response(['message' => 'An unexpected error occurred.'], 500),
'*lemonsqueezy.com/v1/usage-records*' => Http::response(['errors' => [['detail' => 'not metered']]], 422),
]);

$owner = User::factory()->active()->create();
$workspace = subscribedWorkspace($owner);
Display::factory()->create(['workspace_id' => $workspace->id]);

expect(Artisan::call('app:update-lemonsqueezy-subscriptions'))->toBe(1);
});

test('a PayPal subscription is reported as unsyncable and skipped rather than failing every hour', function () {
// Lemon Squeezy cannot change PayPal subscriptions through the API (the customer has
// to do it in the portal); the subscription-items endpoint answers with a 500. Two
// production subscriptions failed the hourly run for months because of this.
Http::fake([
'*lemonsqueezy.com/v1/subscriptions/*' => Http::response([
'data' => ['attributes' => [
'status' => 'active',
'product_id' => 1,
'variant_id' => 1,
'payment_processor' => 'paypal',
'renews_at' => now()->addMonth()->toIso8601String(),
'ends_at' => null,
'first_subscription_item' => ['id' => 'item_1', 'quantity' => 1],
'subscription_items' => [['id' => 'item_1']],
]],
]),
'*lemonsqueezy.com/v1/subscription-items/*' => Http::response(['message' => 'An unexpected error occurred.'], 500),
'*lemonsqueezy.com/v1/usage-records*' => Http::response(['errors' => [['detail' => 'not metered']]], 422),
]);

$owner = User::factory()->active()->create();
$workspace = subscribedWorkspace($owner);
Display::factory()->count(3)->create(['workspace_id' => $workspace->id]);

$exitCode = Artisan::call('app:update-lemonsqueezy-subscriptions');
$output = Artisan::output();

expect($exitCode)->toBe(0)
->and($output)->toContain('PayPal')
->and($output)->toContain('billed for 1, uses 3');
});