get webhooks to sync subscription status

This commit is contained in:
Atharva Deosthale
2025-11-15 15:42:13 +05:30
parent 90e5824f9d
commit 16f7031799
4 changed files with 123 additions and 184 deletions
+2
View File
@@ -84,6 +84,7 @@ return [
[ '$id' => ID::custom('projectInternalId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('subscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerSubscriptionId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('providerCheckoutId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => false, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorType'), 'type' => Database::VAR_STRING, 'size' => 16, 'signed' => true, 'required' => true, 'default' => 'user', 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
[ '$id' => ID::custom('actorInternalId'), 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'signed' => true, 'required' => true, 'default' => null, 'array' => false, 'filters' => [] ],
@@ -105,6 +106,7 @@ return [
[ '$id' => ID::custom('_key_status'), 'type' => Database::INDEX_KEY, 'attributes' => ['status'], 'lengths' => [32], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_plan'), 'type' => Database::INDEX_KEY, 'attributes' => ['planId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_provider_subscription'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerSubscriptionId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_key_provider_checkout'), 'type' => Database::INDEX_KEY, 'attributes' => ['providerCheckoutId'], 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC] ],
[ '$id' => ID::custom('_fulltext_search'), 'type' => Database::INDEX_FULLTEXT, 'attributes' => ['search'], 'lengths' => [], 'orders' => [] ],
],
],
@@ -20,8 +20,6 @@ interface Adapter
public function deleteFeature(ProviderFeatureRef $feature, ProviderPlanRef $plan, ProviderState $state): void;
public function ensureSubscription(Document $actor, array $subscriptionData, ProviderState $state): ProviderSubscriptionRef;
public function updateSubscription(ProviderSubscriptionRef $subscription, array $changes, ProviderState $state): ProviderSubscriptionRef;
public function cancelSubscription(ProviderSubscriptionRef $subscription, bool $atPeriodEnd, ProviderState $state): ProviderSubscriptionRef;
+74 -149
View File
@@ -420,71 +420,6 @@ class StripeAdapter implements Adapter
// Stripe meters typically continue to exist; no deletion API needed for now
}
public function ensureSubscription(Document $actor, array $subscriptionData, ProviderState $state): ProviderSubscriptionRef
{
$apiKey = (string) ($state->config['secretKey'] ?? '');
$customerId = $this->ensureCustomer($apiKey, $actor);
$planRefs = (array) ($subscriptionData['planProviders'] ?? []);
$providerEntry = (array) ($planRefs['stripe'] ?? []);
$priceMap = (array) ($providerEntry['prices'] ?? []);
$desiredInternalPriceId = (string) ($subscriptionData['priceId'] ?? '');
$providerPriceId = '';
if ($desiredInternalPriceId !== '' && isset($priceMap[$desiredInternalPriceId])) {
$providerPriceId = (string) $priceMap[$desiredInternalPriceId];
}
if ($providerPriceId === '') {
$metadataPrices = (array) (($providerEntry['metadata']['prices'] ?? []) ?: []);
if ($desiredInternalPriceId !== '' && isset($metadataPrices[$desiredInternalPriceId])) {
$providerPriceId = (string) $metadataPrices[$desiredInternalPriceId];
} elseif (!empty($priceMap)) {
$first = reset($priceMap);
$providerPriceId = (string) $first;
} elseif (!empty($metadataPrices)) {
$first = reset($metadataPrices);
$providerPriceId = (string) $first;
}
}
if ($providerPriceId === '' && !empty($providerEntry['prices'])) {
$legacy = (array) $providerEntry['prices'];
if (\array_is_list($legacy) && isset($legacy[0])) {
$providerPriceId = (string) $legacy[0];
}
}
if ($providerPriceId === '') {
return new ProviderSubscriptionRef(externalSubscriptionId: '');
}
$resp = $this->request($apiKey, 'POST', '/subscriptions', [
'customer' => $customerId,
'items' => [ [ 'price' => $providerPriceId ] ],
'payment_behavior' => 'default_incomplete',
'metadata' => [ 'project_id' => $this->project->getId(), 'actor_id' => $actor->getId() ]
]);
$respData = $this->decodeResponse($resp);
// Map Stripe status to internal status
$stripeStatus = (string) ($respData['status'] ?? 'incomplete');
$statusMap = [
'active' => 'active',
'trialing' => 'trialing',
'canceled' => 'canceled',
'unpaid' => 'past_due',
'past_due' => 'past_due',
'incomplete' => 'pending',
'incomplete_expired' => 'canceled',
'paused' => 'paused',
];
$internalStatus = $statusMap[$stripeStatus] ?? 'pending';
return new ProviderSubscriptionRef(
externalSubscriptionId: (string) ($respData['id'] ?? ''),
metadata: ['status' => $internalStatus]
);
}
public function updateSubscription(ProviderSubscriptionRef $subscription, array $changes, ProviderState $state): ProviderSubscriptionRef
{
$apiKey = (string) ($state->config['secretKey'] ?? '');
@@ -533,7 +468,14 @@ class StripeAdapter implements Adapter
];
$sessionResponse = $this->request($apiKey, 'POST', '/checkout/sessions', $params);
$sessionData = $this->decodeResponse($sessionResponse);
return new ProviderCheckoutSession(url: (string) ($sessionData['url'] ?? ''));
return new ProviderCheckoutSession(
url: (string) ($sessionData['url'] ?? ''),
metadata: [
'id' => (string) ($sessionData['id'] ?? ''),
'subscriptionId' => (string) ($sessionData['subscription'] ?? ''),
'customerId' => (string) ($sessionData['customer'] ?? ''),
]
);
}
public function createPortalSession(Document $actor, ProviderState $state, array $options = []): ProviderPortalSession
@@ -605,98 +547,81 @@ class StripeAdapter implements Adapter
$type = (string) ($payload['type'] ?? '');
$changes = [];
$apiKey = (string) ($state->config['secretKey'] ?? '');
// error_log(print_r($state, true));
error_log(print_r($this->config, true));
$apiKey = $state->config['secretKey'];
error_log(print_r($type, true));
if (str_starts_with($type, 'customer.subscription.')) {
// /** @var array<string,mixed> $obj */
// $obj = (array) ($payload['data']['object'] ?? []);
// $stripeSubId = (string) ($obj['id'] ?? '');
// $stripeStatus = (string) ($obj['status'] ?? '');
// $periodStart = isset($obj['current_period_start']) ? date('c', (int) $obj['current_period_start']) : null;
// $periodEnd = isset($obj['current_period_end']) ? date('c', (int) $obj['current_period_end']) : null;
// $statusMap = [
// 'active' => 'active',
// 'trialing' => 'trialing',
// 'canceled' => 'canceled',
// 'unpaid' => 'past_due',
// 'past_due' => 'past_due',
// 'incomplete' => 'pending',
// 'incomplete_expired' => 'canceled',
// 'paused' => 'paused',
// ];
// $internalStatus = $statusMap[$stripeStatus] ?? 'active';
// $subs = $this->dbForPlatform->find('payments_subscriptions', [
// Query::equal('projectId', [$this->project->getId()])
// ]);
// foreach ($subs as $sub) {
// /** @var Document $sub */
// $providerMap = (array) $sub->getAttribute('providers', []);
// $prov = (array) ($providerMap['stripe'] ?? []);
// if ((string) ($prov['subscriptionId'] ?? '') === $stripeSubId) {
// $sub->setAttribute('status', $internalStatus);
// if ($periodStart) {
// $sub->setAttribute('currentPeriodStart', $periodStart);
// }
// if ($periodEnd) {
// $sub->setAttribute('currentPeriodEnd', $periodEnd);
// }
// $this->dbForPlatform->updateDocument('payments_subscriptions', $sub->getId(), $sub);
// $changes['subscription'] = $sub->getId();
// $changes['status'] = $internalStatus;
// break;
// }
// }
// error_log(print_r($payload, true));
$stripeSubId = (string) ($payload['data']['object']['id'] ?? '');
$response = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId);
$subData = $this->decodeResponse($response);
error_log("subData: " . print_r($subData, true));
if ($stripeSubId === '') {
return new ProviderWebhookResult(status: 'ignored', changes: []);
}
error_log("stripeSubId: " . $stripeSubId);
try {
$response = $this->request($apiKey, 'GET', '/subscriptions/' . $stripeSubId);
$subData = $this->decodeResponse($response);
} catch (\Throwable $e) {
return new ProviderWebhookResult(status: 'error', changes: ['error' => $e->getMessage()]);
}
// PROBLEM: Always returns null, figure out why.
$sub = Authorization::skip(fn () => $this->dbForPlatform->findOne('payments_subscriptions', [
Query::equal("providerSubscriptionId", [$stripeSubId]),
/** @var Document|null $subscription */
$subscription = Authorization::skip(fn () => $this->dbForPlatform->findOne('payments_subscriptions', [
Query::equal('providerSubscriptionId', [$stripeSubId]),
]));
error_log("sub final: " . print_r($sub, true));
// TODO: Move subscriptionId to a different column called providerSubscriptionId to be able to proceed with this.
$sub['status'] = $subData['status'];
error_log(print_r($sub, true));
// error_log(print_r($sub, true));
Authorization::skip(fn () => $this->dbForPlatform->updateDocument('payments_subscriptions', $sub['$id'], $sub));
return new ProviderWebhookResult(status: 'ok', changes: $changes);
}
if ($type === 'invoice.payment_succeeded' || $type === 'invoice.payment_failed') {
/** @var array<string,mixed> $obj */
$obj = (array) ($payload['data']['object'] ?? []);
$stripeSubId = (string) ($obj['subscription'] ?? '');
$internalStatus = $type === 'invoice.payment_succeeded' ? 'active' : 'past_due';
$subs = $this->dbForPlatform->find('payments_subscriptions', [
Query::equal('projectId', [$this->project->getId()])
]);
foreach ($subs as $sub) {
/** @var Document $sub */
$prov = (array) ((array) $sub->getAttribute('providers', []))['stripe'] ?? [];
if ((string) ($prov['subscriptionId'] ?? '') === $stripeSubId) {
$sub->setAttribute('status', $internalStatus);
$this->dbForPlatform->updateDocument('payments_subscriptions', $sub->getId(), $sub);
$changes['subscription'] = $sub->getId();
$changes['status'] = $internalStatus;
break;
if ((!$subscription instanceof Document || $subscription->isEmpty()) && $type === 'customer.subscription.created') {
try {
$sessionsResponse = $this->request($apiKey, 'GET', '/checkout/sessions', [
'subscription' => $stripeSubId,
'limit' => 1,
]);
$sessionsData = $this->decodeResponse($sessionsResponse);
$sessionId = (string) ($sessionsData['data'][0]['id'] ?? '');
if ($sessionId !== '') {
$subscription = Authorization::skip(fn () => $this->dbForPlatform->findOne('payments_subscriptions', [
Query::equal('providerCheckoutId', [$sessionId]),
]));
}
} catch (\Throwable $_) {
$subscription = null;
}
}
if (!$subscription instanceof Document || $subscription->isEmpty()) {
return new ProviderWebhookResult(status: 'not_found', changes: []);
}
$stripeStatus = strtolower((string) ($subData['status'] ?? ''));
$statusMap = [
'active' => 'active',
'trialing' => 'trialing',
'canceled' => 'canceled',
'unpaid' => 'past_due',
'past_due' => 'past_due',
'incomplete' => 'pending',
'incomplete_expired' => 'canceled',
'paused' => 'paused',
];
$internalStatus = $statusMap[$stripeStatus] ?? 'active';
$periodStart = isset($subData['current_period_start']) ? date('c', (int) $subData['current_period_start']) : null;
$periodEnd = isset($subData['current_period_end']) ? date('c', (int) $subData['current_period_end']) : null;
$providers = (array) $subscription->getAttribute('providers', []);
$providerEntry = (array) ($providers['stripe'] ?? []);
$providerEntry['providerSubscriptionId'] = $stripeSubId;
$providers['stripe'] = $providerEntry;
$subscription->setAttribute('status', $internalStatus);
if ($periodStart) {
$subscription->setAttribute('currentPeriodStart', $periodStart);
}
if ($periodEnd) {
$subscription->setAttribute('currentPeriodEnd', $periodEnd);
}
$subscription->setAttribute('providers', $providers);
Authorization::skip(fn () => $this->dbForPlatform->updateDocument('payments_subscriptions', $subscription->getId(), $subscription));
$changes['subscription'] = $subscription->getId();
$changes['status'] = $internalStatus;
return new ProviderWebhookResult(status: 'ok', changes: $changes);
}
return new ProviderWebhookResult(status: 'ok', changes: $changes);
@@ -4,6 +4,7 @@ namespace Appwrite\Platform\Modules\Payments\Http\Subscriptions;
use Appwrite\Event\Event;
use Appwrite\Payments\Provider\Registry;
use Appwrite\Payments\Provider\StripeAdapter;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -150,6 +151,8 @@ class Create extends Base
$primary = array_key_first($providers);
$initialStatus = 'pending';
$providerData = [];
$providerSubscriptionId = null;
$providerCheckoutId = null;
$checkoutUrl = null;
$selectedPriceId = $priceId !== '' ? $priceId : null;
$providerPlanPriceId = '';
@@ -225,41 +228,51 @@ class Create extends Base
return;
}
// Create checkout session if we have a price ID and URLs
if ($providerPlanPriceId && $successUrl !== '' && $cancelUrl !== '') {
try {
$checkoutSession = $adapter->createCheckoutSession($payer, [
'priceId' => $providerPlanPriceId
], $state, [
'successUrl' => $successUrl,
'cancelUrl' => $cancelUrl
]);
$checkoutUrl = $checkoutSession->url;
error_log('Checkout URL: ' . $checkoutUrl);
} catch (\Throwable $e) {
// Log error but continue with subscription creation
// The subscription can be created without checkout URL for manual payment flows
}
}
$providerKey = (string) $primary;
$providerEntryData = [];
$providerSubscriptionId = null;
$providerCheckoutId = null;
// Create or ensure subscription exists in provider
$subRef = null;
try {
$subRef = $adapter->ensureSubscription($payer, [
'planId' => $planId,
'planProviders' => $planProviders,
'priceId' => (string) ($selectedPriceId ?? '')
], $state);
$providerData = [ (string) $primary => [ 'providerSubscriptionId' => $subRef->externalSubscriptionId ] ];
$providerData[(string) $primary]['priceId'] = (string) ($selectedPriceId ?? '');
$providerData[(string) $primary]['providerPriceId'] = (string) $providerPlanPriceId;
// Use status from provider if available
$initialStatus = (string) ($subRef->metadata['status'] ?? 'pending');
} catch (\Throwable $e) {
$response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
$response->json(['message' => 'Failed to create subscription: ' . $e->getMessage()]);
if (!$adapter instanceof StripeAdapter) {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['message' => 'Unsupported payment provider: ' . $providerKey]);
return;
}
if ($successUrl === '' || $cancelUrl === '') {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['message' => 'successUrl and cancelUrl are required.']);
return;
}
try {
$checkoutSession = $adapter->createCheckoutSession($payer, [
'priceId' => $providerPlanPriceId
], $state, [
'successUrl' => $successUrl,
'cancelUrl' => $cancelUrl
]);
} catch (\Throwable $e) {
$response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
$response->json(['message' => 'Failed to create checkout session: ' . $e->getMessage()]);
return;
}
$checkoutUrl = $checkoutSession->url;
$providerCheckoutId = (string) ($checkoutSession->metadata['id'] ?? '');
if ($providerCheckoutId === '') {
$response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
$response->json(['message' => 'Checkout session did not return an id']);
return;
}
$providerEntryData = [
'priceId' => (string) ($selectedPriceId ?? ''),
'providerPriceId' => (string) $providerPlanPriceId,
'providerCheckoutId' => $providerCheckoutId,
];
$initialStatus = 'pending';
$providerData[$providerKey] = $providerEntryData;
} else {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
$response->json(['message' => 'No payment provider configured for this project']);
@@ -270,7 +283,8 @@ class Create extends Base
$subscription = new Document([
'subscriptionId' => ID::unique(),
'providerSubscriptionId' => $subRef->externalSubscriptionId,
'providerSubscriptionId' => $providerSubscriptionId,
'providerCheckoutId' => $providerCheckoutId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'actorType' => $actorType,