Charge Stored Card

The ChargeStoredCard effect charges a patient’s stored payment card through the payment processor configured for your Canvas instance. The charge is processed server-side against the tokenized card, so no card data crosses the plugin boundary.

You reference the card by an identifier the configured processor understands, never the underlying processor token:

Imports #

from canvas_sdk.effects.payment import ChargeStoredCard

Charging a stored card #

Import the ChargeStoredCard class, create an instance of it, and return its .apply() method from compute.

Attribute TypeDescription
patient_idrequiredStringThe Canvas Patient id to charge.
payment_card_idrequiredStringA reference to the stored card that the configured processor resolves: the Canvas PaymentCard id for the built-in Stripe processor, or a custom processor’s own reference.
amountrequiredDecimalThe amount to charge, in dollars, with up to two decimal places (for example, Decimal("49.99")). Must be greater than 0.
idempotency_keyrequiredUUID or StringA key that makes the charge safe to retry. Reusing the same key for a retry guarantees the patient is not charged twice. See Idempotency.
claim_idoptional*UUID or StringA Claim id to post the payment against. When omitted, the payment is allocated across the patient’s outstanding balance. See Payment allocation.
copayoptionalBooleanWhether the charge is a copay. When true, the amount is posted to the claim’s copay line item; requires claim_id. Defaults to false. See Payment allocation.
descriptionoptionalStringA free-text description to record with the payment. It has no length limit, and is recorded only on a charge that names a claim_id.

* claim_id is required when copay is true.

Example:

from decimal import Decimal
from uuid import uuid5, NAMESPACE_URL

from canvas_sdk.effects import Effect
from canvas_sdk.effects.payment import ChargeStoredCard
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from canvas_sdk.v1.data import Patient


class ChargeBalanceOnBooking(BaseHandler):
    RESPONDS_TO = [EventType.Name(EventType.APPOINTMENT_CREATED)]

    def compute(self) -> list[Effect]:
        # APPOINTMENT_CREATED targets the appointment; the patient is in the context.
        appointment_id = self.event.target.id
        patient = Patient.objects.get(id=self.event.context["patient"]["id"])

        card = patient.payment_cards.filter(is_default=True).first()
        if card is None:
            return []

        charge = ChargeStoredCard(
            patient_id=str(patient.id),
            payment_card_id=str(card.id),
            amount=Decimal("25.00"),
            idempotency_key=uuid5(NAMESPACE_URL, f"appointment-{appointment_id}-balance"),
        )
        return [charge.apply()]

This charge names no claim_id, so it pays down the patient’s outstanding balance and is rejected unless that balance covers the amount. To take a copay instead, supply the claim_id it belongs to and set copay=True. See Payment allocation.

Validation #

.apply() raises a ValidationError when:

  • The patient_id does not match a patient.
  • A supplied claim_id does not match a claim.
  • copay is set without a claim_id.

The card itself is not checked here. payment_card_id is resolved and validated server-side when the charge is processed, so a reference the configured processor cannot resolve arrives as an error on the response event rather than raising from .apply(). See Error codes for the code each processor reports.

Card ownership #

Under the built-in Stripe processor, Canvas looks the card up scoped to patient_id, so a card belonging to a different patient, or one that has been removed, is rejected as card_not_found before the card is charged.

A custom payment processor keeps its own cards, which Canvas cannot see, so it passes payment_card_id through without checking it. On a custom processor, confirming that a card reference belongs to the patient being charged is the processor’s responsibility.

Idempotency #

Every charge requires an idempotency_key. Pass either a UUID object or its string form, dashed or not; a string that does not parse as a UUID is rejected.

Reusing the same key on a retry guarantees the patient is not charged twice, so the key must be stable across retries of the same logical charge. Generate it deterministically from a stable identifier — for example uuid5(namespace, f"appointment-{id}-charge") — or persist a uuid4 before emitting the effect. Do not generate a fresh key on each attempt.

Payment allocation #

The payment is always applied to the patient’s account:

  • Outstanding balance (default). When no claim_id is given, the payment is allocated across the patient’s outstanding balance. This is the only case where the amount is measured against a balance: the charge is rejected before the card is touched if the patient has no outstanding balance, and again if the amount exceeds it, so every charged dollar lands on a claim.
  • Against a specific claim. When claim_id is given and copay is left false (the default), the payment pays down that claim’s non-copay line-item balances. The charge is rejected if the claim has nothing to charge against, but the amount is not capped at the claim’s balance, so an amount above it leaves the claim in credit.
  • As a copay on a claim. When claim_id is given with copay set to true, the amount is recorded as a copay on that claim, posted to its copay line item. No balance is checked at all, so use this to collect a copay or prepayment against a claim that carries nothing to charge.

Reconciling the charge #

Once the charge has been processed, Canvas emits a REVENUE__STORED_CARD__CHARGE_RESPONSE event carrying the outcome. It fires whether or not the card was charged, and nothing a handler returns changes the charge, so handle it to reconcile rather than to intervene.

The event targets the patient that was charged: self.event.target.id is the patient id, and self.event.target.instance is the Patient. Its actor is the one that emitted the originating effect.

Correlate each response with the charge that produced it using the idempotency_key you supplied on the request. It is the only field that ties the two together, since nothing in the response identifies the handler that asked for the charge.

This handler records the result of each charge, reading payment_intent_id when it succeeds and the error object when it does not:

from canvas_sdk.effects import Effect
from canvas_sdk.events import EventType
from canvas_sdk.handlers import BaseHandler
from logger import log


class ReconcileStoredCardCharge(BaseHandler):
    RESPONDS_TO = [EventType.Name(EventType.REVENUE__STORED_CARD__CHARGE_RESPONSE)]

    def compute(self) -> list[Effect]:
        context = self.event.context
        idempotency_key = context["idempotency_key"]

        if context["success"]:
            payment_intent_id = context["payment_intent_id"]
            log.info(f"Charge {idempotency_key} succeeded: {payment_intent_id}")
        else:
            error = context["error"]
            log.error(f"Charge {idempotency_key} failed [{error['code']}]: {error['message']}")

        return []

Response context #

self.event.context arrives already parsed as a dictionary, so read the fields from it directly. It carries no card data or PHI. Apart from success, which is a boolean, and error, which is an object, every value is a string or null, including the amount and the ids that were UUIDs on the request, so cast amount before doing arithmetic on it.

FieldTypeDescription
successBooleanWhether the charge succeeded.
payment_intent_idString or nullThe processor’s payment identifier on success — the Stripe PaymentIntent id, or a custom processor’s transaction id. null on failure.
errorObject or nullnull on success. On failure, error is an object with code and message fields. See Error codes.
idempotency_keyStringEchoed from the request. Correlate the response with the originating charge using this value.
patient_idStringEchoed from the request: the Patient that was charged.
payment_card_idStringEchoed from the request: the stored card that was charged, a PaymentCard id under the built-in processor.
claim_idString or nullEchoed from the request: the Claim the payment was posted against, or null when none was supplied.
amountStringEchoed from the request: the dollar amount that was submitted.

Error codes #

On failure, error.code names the condition that stopped the charge.

error.codeWhen it occurs
patient_not_foundThe patient_id did not match a patient.
card_not_foundThe stored card was not found. For the built-in Stripe processor, this also covers a card that exists but is not owned by the patient; a custom processor may raise a different code for that case — check the processor’s own documentation.
claim_not_foundThe claim_id did not match a claim owned by the patient.
no_outstanding_balanceA charge with no claim_id was attempted, but the patient has no outstanding balance. The balance is checked before the card is charged.
amount_exceeds_balanceA charge with no claim_id exceeds the patient’s outstanding balance. The amount is validated against the balance before the card is charged.
nothing_to_chargeA per-claim charge with copay set to false was attempted, but the claim has no chargeable, non-copay balance. Set copay to true to record a copay or prepayment instead.
charge_failedAn unexpected failure occurred while processing the charge. Retry using the same idempotency_key; the retry will not double-charge the patient.

The payment processor’s own decline codes also pass through in error.code — for example, card_declined from Stripe — and can arrive at charge time. Handle an unrecognized code gracefully rather than matching against a fixed set, since the processor can surface codes this list does not name.